diff --git a/app/mobile/src/access/admin/useAdmin.hook.js b/app/mobile/src/access/admin/useAdmin.hook.js index 81994f2f..2aa06ef0 100644 --- a/app/mobile/src/access/admin/useAdmin.hook.js +++ b/app/mobile/src/access/admin/useAdmin.hook.js @@ -4,7 +4,7 @@ import { useNavigate } from 'react-router-dom'; import { AppContext } from 'context/AppContext'; import { getNodeStatus } from 'api/getNodeStatus'; import { setNodeStatus } from 'api/setNodeStatus'; -import { getNodeConfig } from 'api/getNodeConfig'; +import { setNodeAccess } from 'api/setNodeAccess'; import { getLanguageStrings } from 'constants/Strings'; export function useAdmin() { @@ -22,6 +22,8 @@ export function useAdmin() { version: null, agree: false, showTerms: false, + + mfaCode: '', }); const updateState = (value) => { @@ -80,14 +82,13 @@ export function useAdmin() { try { updateState({ busy: true }); const node = state.server.trim(); - const token = state.token; const unclaimed = await getNodeStatus(node); if (unclaimed) { - await setNodeStatus(node, token); + await setNodeStatus(node, state.token); } - const config = await getNodeConfig(node, token); + const session = await setNodeAccess(node, state.token, state.mfaCode); updateState({ server: node, busy: false }); - navigate('/dashboard', { state: { config, server: node, token }}); + navigate('/dashboard', { state: { server: node, token: session }}); } catch (err) { console.log(err); diff --git a/app/mobile/src/api/addAdminMFAuth.js b/app/mobile/src/api/addAdminMFAuth.js new file mode 100644 index 00000000..66f22416 --- /dev/null +++ b/app/mobile/src/api/addAdminMFAuth.js @@ -0,0 +1,11 @@ +import { checkResponse, fetchWithTimeout } from './fetchUtil'; + +export async function addAdminMFAuth(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}/admin/mfauth?token=${token}`, { method: 'POST' }) + checkResponse(mfa); + return mfa.json(); +} + diff --git a/app/mobile/src/api/getAdminMFAuth.js b/app/mobile/src/api/getAdminMFAuth.js new file mode 100644 index 00000000..2723dd03 --- /dev/null +++ b/app/mobile/src/api/getAdminMFAuth.js @@ -0,0 +1,11 @@ +import { checkResponse, fetchWithTimeout } from './fetchUtil'; + +export async function getAdminMFAuth(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}/admin/mfauth?token=${encodeURIComponent(token)}`, { method: 'GET' }); + checkResponse(mfa); + return await mfa.json(); +} + diff --git a/app/mobile/src/api/removeAdminMFAuth.js b/app/mobile/src/api/removeAdminMFAuth.js new file mode 100644 index 00000000..f08fde4d --- /dev/null +++ b/app/mobile/src/api/removeAdminMFAuth.js @@ -0,0 +1,10 @@ +import { checkResponse, fetchWithTimeout } from './fetchUtil'; + +export async function removeAdminMFAuth(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}/admin/mfauth?token=${token}`, { method: 'DELETE' }) + checkResponse(mfa); +} + diff --git a/app/mobile/src/api/setAdminMFAuth.js b/app/mobile/src/api/setAdminMFAuth.js new file mode 100644 index 00000000..99b7e3b1 --- /dev/null +++ b/app/mobile/src/api/setAdminMFAuth.js @@ -0,0 +1,10 @@ +import { checkResponse, fetchWithTimeout } from './fetchUtil'; + +export async function setAdminMFAuth(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}/admin/mfauth?token=${token}&code=${code}`, { method: 'PUT' }) + checkResponse(mfa); +} + diff --git a/app/mobile/src/api/setNodeAccess.js b/app/mobile/src/api/setNodeAccess.js new file mode 100644 index 00000000..bb14e4ef --- /dev/null +++ b/app/mobile/src/api/setNodeAccess.js @@ -0,0 +1,12 @@ +import { checkResponse, fetchWithTimeout } from './fetchUtil'; + +export async function setNodeAccess(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 = code ? `&code=${code}` : ''; + + const access = await fetchWithTimeout(`${protocol}://${server}/admin/access?token=${encodeURIComponent(token)}${mfa}`, { method: 'PUT' }); + checkResponse(access); + return access.json() +} + diff --git a/app/mobile/src/constants/Strings.js b/app/mobile/src/constants/Strings.js index f13e040b..52dfba50 100644 --- a/app/mobile/src/constants/Strings.js +++ b/app/mobile/src/constants/Strings.js @@ -210,6 +210,10 @@ const Strings = [ mfaDisabled: 'verification temporarily disabled', mfaConfirm: 'Confirm', mfaEnter: 'Enter your verification code', + + disable: 'Disable', + confirmDisable: 'Disabling Multi-Factor Authentication', + disablePrompt: 'Are you sure you want to disable multi-factor authentication', }, { languageCode: 'fr', @@ -416,6 +420,10 @@ const Strings = [ mfaError: 'erreur de code de vérification', mfaDisabled: 'vérification temporairement désactivée', mfaConfirm: 'Confirmer', + + disable: 'Désactiver', + confirmDisable: 'Désactivation de l\'authentification multi-facteurs', + disablePrompt: 'Êtes-vous sûr de vouloir désactiver l\'authentification multi-facteurs', }, { languageCode: 'es', @@ -623,6 +631,10 @@ const Strings = [ mfaError: 'error de código de verificación', mfaDisabled: 'verificación temporalmente deshabilitada', mfaConfirm: 'Confirmar', + + disable: 'Desactivar', + confirmDisable: 'Desactivación de la autenticación de dos factores', + disablePrompt: '¿Estás seguro de que quieres desactivar la autenticación de dos factores?', }, { languageCode: 'de', @@ -830,6 +842,10 @@ const Strings = [ mfaError: 'Verifizierungscodefehler', mfaDisabled: 'Verifizierung vorübergehend deaktiviert', mfaConfirm: 'Bestätigen', + + disable: 'Deaktivieren', + confirmDisable: 'Deaktivierung der Zwei-Faktor-Authentifizierung', + disablePrompt: 'Sind Sie sicher, dass Sie die Zwei-Faktor-Authentifizierung deaktivieren möchten?', }, { languageCode: 'pt', @@ -1022,6 +1038,10 @@ const Strings = [ mfaError: 'erro de código de verificação', mfaDisabled: 'verificação temporariamente desativada', mfaConfirm: 'Confirmar', + + disable: 'Desativar', + confirmDisable: 'Desativando Autenticação de Dois Fatores', + disablePrompt: 'Tem certeza de que deseja desativar a autenticação de dois fatores?', }, { languageCode: 'ru', @@ -1212,6 +1232,10 @@ const Strings = [ mfaError: 'ошибка проверочного кода', mfaDisabled: 'проверка временно отключена', mfaConfirm: 'Подтвердить', + + disable: 'Отключить', + confirmDisable: 'Отключение двухфакторной аутентификации', + disablePrompt: 'Вы уверены, что хотите отключить двухфакторную аутентификацию?', } ]; diff --git a/app/mobile/src/dashboard/Dashboard.jsx b/app/mobile/src/dashboard/Dashboard.jsx index 88b58047..15e82e62 100644 --- a/app/mobile/src/dashboard/Dashboard.jsx +++ b/app/mobile/src/dashboard/Dashboard.jsx @@ -1,4 +1,4 @@ -import { ScrollView, TextInput, Alert, Switch, TouchableOpacity, View, Text, Modal, FlatList, KeyboardAvoidingView } from 'react-native'; +import { ScrollView, Image, TextInput, Alert, Switch, ActivityIndicator, TouchableOpacity, View, Text, Modal, FlatList, KeyboardAvoidingView } from 'react-native'; import Clipboard from '@react-native-clipboard/clipboard'; import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; import AntIcon from 'react-native-vector-icons/AntDesign'; @@ -9,12 +9,50 @@ import { useDashboard } from './useDashboard.hook'; import { Logo } from 'utils/Logo'; import { BlurView } from "@react-native-community/blur"; import { InputField } from 'utils/InputField'; +import Colors from 'constants/Colors'; +import { InputCode } from 'utils/InputCode'; export function Dashboard(props) { const location = useLocation(); - const { config, server, token } = location.state; - const { state, actions } = useDashboard(config, server, token); + const { server, token } = location.state; + const { state, actions } = useDashboard(server, token); + + const enableMFA = async () => { + try { + await actions.enableMFA(); + } + catch (err) { + Alert.alert( + state.strings.error, + state.strings.tryAgain, + ); + } + } + + const disableMFA = async () => { + try { + await actions.disableMFA(); + } + catch (err) { + Alert.alert( + state.strings.error, + state.strings.tryAgain, + ); + } + } + + const confirmMFA = async () => { + try { + await actions.confirmMFA(); + } + catch (err) { + Alert.alert( + state.strings.error, + state.strings.tryAgain, + ); + } + } const saveConfig = async () => { try { @@ -24,8 +62,8 @@ export function Dashboard(props) { catch (err) { console.log(err); Alert.alert( - 'Failed to Save Settings', - 'Please try again.', + state.strings.error, + state.strings.tryAgain, ); } } @@ -37,8 +75,8 @@ export function Dashboard(props) { catch (err) { console.log(err); Alert.alert( - 'Failed to Generate Access Token', - 'Please try again.', + state.strings.error, + state.strings.tryAgain, ); } } @@ -50,8 +88,8 @@ export function Dashboard(props) { catch (err) { console.log(err); Alert.alert( - 'Failed to Generate Access Token', - 'Please try again.', + state.strings.error, + state.strings.tryAgain, ); } } @@ -67,8 +105,8 @@ export function Dashboard(props) { catch (err) { console.log(err); Alert.alert( - 'Failed to Update Account', - 'Please try again.', + state.strings.error, + state.strings.tryAgain, ); } } @@ -83,6 +121,16 @@ export function Dashboard(props) { + { !state.mfaEnabled && ( + + + + )} + { state.mfaEnabled && ( + + + + )} @@ -340,6 +388,60 @@ export function Dashboard(props) { + + + + + + { 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/dashboard/Dashboard.styled.js b/app/mobile/src/dashboard/Dashboard.styled.js index a6aa36fa..40b6e1b7 100644 --- a/app/mobile/src/dashboard/Dashboard.styled.js +++ b/app/mobile/src/dashboard/Dashboard.styled.js @@ -302,4 +302,110 @@ export const styles = StyleSheet.create({ marginBottom: 8, }, }, + mfaOverlay: { + width: '100%', + height: '100%', + }, + mfaSecret: { + display: 'flex', + flexDirection: 'row', + gap: 4, + }, + mfaText: { + fontSize: 11, + color: Colors.labelText, + }, + 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, + }, }); diff --git a/app/mobile/src/dashboard/useDashboard.hook.js b/app/mobile/src/dashboard/useDashboard.hook.js index 1237c4e9..2342505d 100644 --- a/app/mobile/src/dashboard/useDashboard.hook.js +++ b/app/mobile/src/dashboard/useDashboard.hook.js @@ -15,11 +15,15 @@ import { addAccountAccess } from 'api/addAccountAccess'; import { DisplayContext } from 'context/DisplayContext'; import { getLanguageStrings } from 'constants/Strings'; -export function useDashboard(config, server, token) { +import { getAdminMFAuth } from 'api/getAdminMFAuth'; +import { addAdminMFAuth } from 'api/addAdminMFAuth'; +import { setAdminMFAuth } from 'api/setAdminMFAuth'; +import { removeAdminMFAuth } from 'api/removeAdminMFAuth'; + +export function useDashboard(server, token) { const [state, setState] = useState({ strings: getLanguageStrings(), - config: null, accounts: [], editConfig: false, addUser: false, @@ -40,6 +44,13 @@ export function useDashboard(config, server, token) { iceUrl: null, iceUsername: null, icePassword: null, + + mfaModal: false, + mfaImage: null, + mfaText: null, + mfaCode: '', + mfaError: null, + mfaEnabled: false, }); const navigate = useNavigate(); @@ -62,19 +73,24 @@ export function useDashboard(config, server, token) { return { logo, name, handle, accountId, disabled }; } - const refreshAccounts = async () => { - const accounts = await getNodeAccounts(server, token); - updateState({ accounts: accounts.map(setAccountItem) }); - }; - - useEffect(() => { - const { keyType, accountStorage, domain, enableImage, enableAudio, enableVideo, enableBinary, transformSupported, allowUnsealed, pushSupported, enableIce, iceUrl, iceUsername, icePassword } = config; + const syncNode = async () => { + const mfaEnabled = await getAdminMFAuth(server, token); + const config = await getNodeConfig(server, token); + const nodeAccounts = await getNodeAccounts(server, token); + const accounts = nodeAccounts.map(setAccountItem); + const { keyType, accountStorage, domain, enableImage, enableAudio, enableVideo, enableBinary, transformSupported, allowUnsealed, pushSupported, enableIce, iceUrl, iceUsername, icePassword } = config || {}; const storage = Math.ceil(accountStorage / 1073741824); - updateState({ keyType, storage: storage.toString(), domain, enableImage, enableAudio, enableVideo, enableBinary, transformSupported, allowUnsealed, pushSupported, enableIce, iceUrl, iceUsername, icePassword }); - }, [config]); + updateState({ keyType, storage: storage.toString(), domain, enableImage, enableAudio, enableVideo, enableBinary, transformSupported, allowUnsealed, pushSupported, enableIce, iceUrl, iceUsername, icePassword, accounts, mfaEnabled }); + } + + const refreshAccounts = async () => { + const nodeAccounts = await getNodeAccounts(server, token); + const accounts = nodeAccounts.map(setAccountItem); + updateState({ accounts }); + } useEffect(() => { - refreshAccounts(); + syncNode(); }, []); const actions = { @@ -155,8 +171,8 @@ export function useDashboard(config, server, token) { }, promptRemove: (accountId) => { display.actions.showPrompt({ - title: 'Delete User', - ok: { label: 'Delete', action: async () => { + title: state.strings.deleteAccount, + ok: { label: state.strings.delete, action: async () => { await removeAccount(server, token, accountId); await refreshAccounts(); } , failed: () => { @@ -168,6 +184,41 @@ export function useDashboard(config, server, token) { cancel: { label: state.strings.cancel }, }); }, + enableMFA: async () => { + updateState({ mfaModal: true, mfaImage: null, mfaText: null, mfaCode: '', mfaError: '' }); + const mfa = await addAdminMFAuth(server, token); + updateState({ mfaImage: mfa.secretImage, mfaText: mfa.secretText }); + }, + disableMFA: async () => { + display.actions.showPrompt({ + title: state.strings.confirmDisable, + ok: { label: state.strings.disable, action: async () => { + await removeAdminMFAuth(server, token); + updateState({ mfaEnabled: false }); + } , failed: () => { + Alert.alert( + state.strings.error, + state.strings.tryAgain, + ); + }}, + cancel: { label: state.strings.cancel }, + }); + }, + confirmMFA: async () => { + try { + await setAdminMFAuth(server, token, state.mfaCode); + updateState({ mfaEnabled: true, mfaModal: false }); + } + catch (err) { + updateState({ mfaError: err.message}); + } + }, + dismissMFA: () => { + updateState({ mfaModal: false }); + }, + setCode: (mfaCode) => { + updateState({ mfaCode }); + }, }; return { state, actions };