support mfa setting in admin setup

This commit is contained in:
balzack 2025-02-19 11:12:50 -08:00
parent db77fbd0a4
commit 9a3b9c4bd0
5 changed files with 222 additions and 5 deletions

View File

@ -124,4 +124,87 @@ export const styles = StyleSheet.create({
controlSwitch: {
transform: [{scaleX: 0.7}, {scaleY: 0.7}],
},
modal: {
display: 'flex',
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
},
blur: {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
},
modalHeader: {
width: '100%',
display: 'flex',
alignItems: 'center',
flexDirection: 'row',
},
modalLabel: {
fontSize: 20,
},
modalClose: {
position: 'absolute',
top: 0,
right: 0,
backgroundColor: 'transparent',
},
modalControls: {
display: 'flex',
flexDirection: 'row',
gap: 16,
justifyContent: 'flex-end',
alignItems: 'center',
marginTop: 16,
},
modalDescription: {
paddingTop: 16,
},
authMessage: {
height: 24,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
paddingTop: 8,
},
authMessageText: {
fontSize: 16,
color: Colors.danger,
},
modalContent: {
display: 'flex',
justifyContent: 'center',
height: '100%',
gap: 8,
},
modalSurface: {
padding: 16,
borderRadius: 8,
},
secretImage: {
width: 192,
height: 192,
alignSelf: 'center',
borderRadius: 8,
margin: 16,
},
secretText: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingBottom: 16,
paddingLeft: 8,
paddingRight: 8,
},
secret: {
paddingRight: 16,
},
secretIcon: {
marginLeft: 8,
},
});

View File

@ -1,13 +1,21 @@
import React from 'react';
import {SafeAreaView, Image, View, Pressable} from 'react-native';
import {ActivityIndicator, RadioButton, Switch, Surface, Divider, TextInput, Text} from 'react-native-paper';
import React, { useState } from 'react';
import {SafeAreaView, TouchableOpacity, Modal, Image, View, Pressable} from 'react-native';
import {ActivityIndicator, Icon, Button, IconButton, RadioButton, Switch, Surface, Divider, TextInput, Text} from 'react-native-paper';
import {styles} from './Setup.styled';
import {useSetup} from './useSetup.hook';
import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view';
import { Confirm } from '../confirm/Confirm';
import { Colors } from '../constants/Colors';
import { InputCode } from '../utils/InputCode';
import {BlurView} from '@react-native-community/blur';
import Clipboard from '@react-native-clipboard/clipboard';
export function Setup() {
const { state, actions } = useSetup();
const [mfaCode, setMfaCode] = useState('');
const [updating, setUpdating] = useState(false);
const [secretCopy, setSecretCopy] = useState(false);
const [confirmingAuth, setConfirmingAuth] = useState(false);
const errorParams = {
title: state.strings.operationFailed,
@ -18,6 +26,36 @@ export function Setup() {
},
};
const confirmAuth = async () => {
if (!confirmingAuth) {
setConfirmingAuth(true);
await actions.confirmMFAuth();
setConfirmingAuth(false);
}
}
const copySecret = async () => {
if (!secretCopy) {
setSecretCopy(true);
Clipboard.setString(state.secretText);
setTimeout(() => {
setSecretCopy(false);
}, 2000);
}
};
const toggleMFAuth = async () => {
if (!updating) {
setUpdating(true);
if (state.mfaEnabled) {
await actions.disableMFAuth();
} else {
await actions.enableMFAuth();
}
setUpdating(false);
}
}
return (
<View style={styles.setup}>
<View style={styles.header}>
@ -133,6 +171,10 @@ export function Setup() {
<Text style={styles.label}>{state.strings.allowUnsealed}:</Text>
<Switch style={styles.controlSwitch} value={state.setup?.allowUnsealed} disabled={state.loading} onValueChange={()=>actions.setAllowUnsealed(!state.setup?.allowUnsealed)} />
</View>
<View style={styles.option}>
<Text style={styles.label}>{state.strings.mfaTitle}:</Text>
<Switch style={styles.controlSwitch} value={state.mfaEnabled} disabled={state.loading} onValueChange={toggleMFAuth} />
</View>
<Divider style={styles.divider} bold={false} />
<View style={styles.option}>
<Text style={styles.label}>{state.strings.enableImage}:</Text>
@ -264,6 +306,43 @@ export function Setup() {
</KeyboardAwareScrollView>
<Divider style={styles.line} bold={true} />
<Confirm show={state.error} params={errorParams} />
<Modal animationType="fade" transparent={true} supportedOrientations={['portrait', 'landscape']} visible={state.confirmingMFAuth} onRequestClose={actions.cancelMFAuth}>
<View style={styles.modal}>
<BlurView style={styles.blur} blurType="dark" blurAmount={2} reducedTransparencyFallbackColor="dark" />
<KeyboardAwareScrollView enableOnAndroid={true} style={styles.container} contentContainerStyle={styles.modalContent}>
<Surface elevation={4} mode="flat" style={styles.modalSurface}>
<Text style={styles.modalLabel}>{state.strings.mfaTitle}</Text>
<IconButton style={styles.modalClose} icon="close" size={24} onPress={actions.cancelMFAuth} />
<Text style={styles.modalDescription}>{state.strings.mfaSteps}</Text>
<Image style={styles.secretImage} resizeMode={'contain'} source={{uri: state.confirmMFAuthImage}} />
<View style={styles.secretText}>
<Text style={styles.secret} selectable={true} adjustsFontSizeToFit={true} numberOfLines={1}>
{state.confirmMFAuthText}
</Text>
<TouchableOpacity onPress={copySecret}>
<Icon style={styles.secretIcon} size={18} source={secretCopy ? 'check' : 'content-copy'} color={Colors.primary} />
</TouchableOpacity>
</View>
<InputCode onChangeText={actions.setMFAuthCode} />
<View style={styles.authMessage}>
<Text style={styles.authMessageText}>{ state.mfaMessage }</Text>
</View>
<View style={styles.modalControls}>
<Button mode="outlined" onPress={actions.cancelMFAuth}>
{state.strings.cancel}
</Button>
<Button mode="contained" loading={confirmingAuth} disabled={state.mfaCode.length !== 6} onPress={confirmAuth}>
{state.strings.mfaConfirm}
</Button>
</View>
</Surface>
</KeyboardAwareScrollView>
</View>
</Modal>
</View>
);
}

View File

@ -22,6 +22,12 @@ export function useSetup() {
error: false,
accountStorage: '',
setup: null as null | Setup,
mfaEnabled: false,
mfaCode: '',
mfaMessage: '',
confirmingMFAuth: false,
confirmMFAuthText: '',
confirmMFAuthImage: '',
});
const updateState = (value: any) => {
@ -32,10 +38,11 @@ export function useSetup() {
while (loading.current) {
try {
const service = app.state.service;
const mfaEnabled = await service.checkMFAuth();
setup.current = await service.getSetup();
loading.current = false;
const storage = Math.floor(setup.current.accountStorage / 1073741824);
updateState({ setup: setup.current, accountStorage: storage.toString(), loading: false });
updateState({ setup: setup.current, mfaEnabled, accountStorage: storage.toString(), loading: false });
} catch (err) {
console.log(err);
await new Promise((r) => setTimeout(r, DELAY_MS));
@ -83,6 +90,47 @@ export function useSetup() {
clearError: () => {
updateState({ error: false });
},
enableMFAuth: async () => {
try {
const service = app.state.service;
const { text, image } = await service.enableMFAuth();
updateState({ confirmingMFAuth: true, mfaCode: '', mfaMessage: '', confirmMFAuthText: text, confirmMFAuthImage: image });
} catch (err) {
console.log(err);
updateState({ error: true });
}
},
disableMFAuth: async () => {
try {
const service = app.state.service;
await service.disableMFAuth();
updateState({ mfaEnabled: false });
} catch (err) {
console.log(err);
updateState({ error: true });
}
},
confirmMFAuth: async () => {
try {
const service = app.state.service;
await service.confirmMFAuth(state.mfaCode);
updateState({ confirmingMFAuth: false, mfaEnabled: true });
} catch (err) {
if (err.message === '401') {
updateState({ mfaMessage: state.strings.mfaError });
} else if (err.message === '429') {
updateState({ mfaMessage: state.strings.mfaDisabled });
} else {
updateState({ mfaMessage: state.strings.error });
}
}
},
cancelMFAuth: () => {
updateState({ confirmingMFAuth: false });
},
setMFAuthCode: (mfaCode: string) => {
updateState({ mfaCode });
},
setDomain: (domain: string) => {
if (setup.current) {
setup.current.domain = domain;

View File

@ -169,6 +169,7 @@ export interface Service {
removeMember(accountId: number): Promise<void>;
getSetup(): Promise<Setup>;
setSetup(setup: Setup): Promise<void>;
checkMFAuth(): Promise<boolean>;
enableMFAuth(): Promise<{ image: string, text: string }>;
confirmMFAuth(code: string): Promise<void>;
disableMFAuth(): Promise<void>;

View File

@ -83,10 +83,16 @@ export class ServiceModule implements Service {
await setNodeConfig(node, secure, token, entity);
}
public async checkMFAuth(): Promise<boolean> {
const { node, secure, token } = this;
const enabled = await getAdminMFAuth(node, secure, token);
return enabled;
}
public async enableMFAuth(): Promise<{ image: string, text: string}> {
const { node, secure, token } = this;
const { secretImage, secretText } = await addAdminMFAuth(node, secure, token);
return { secretImage, secretText };
return { image: secretImage, text: secretText };
}
public async disableMFAuth(): Promise<void> {