mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 11:18:24 +00:00
feat: enable device PIN fallback for authentication
This commit is contained in:
committed by
minibits-cash
parent
3909660529
commit
14cfb5302b
+74
-21
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import FlashMessage from "react-native-flash-message"
|
||||
import {
|
||||
SafeAreaProvider,
|
||||
@@ -14,9 +14,8 @@ import {KeyChain, WalletKeys} from './services'
|
||||
import {ErrorBoundary} from './screens/ErrorScreen/ErrorBoundary'
|
||||
import Config from './config'
|
||||
import {log} from './services'
|
||||
import AppError, { Err } from './utils/AppError'
|
||||
import { Image, TextStyle, View } from 'react-native'
|
||||
import { spacing, typography } from './theme'
|
||||
import { Image, Pressable, Text as RNText, TextStyle, View } from 'react-native'
|
||||
import { colors, spacing, typography } from './theme'
|
||||
import { displayName } from '../app.json'
|
||||
import { Text } from './components/Text'
|
||||
import useIsInternetReachable from './utils/useIsInternetReachable'
|
||||
@@ -35,6 +34,8 @@ function App() {
|
||||
|
||||
const [isUserAuthenticated, setIsUserAuthenticated] = useState(false)
|
||||
const [isDeviceAuthenticated, setIsDeviceAuthenticated] = useState(false)
|
||||
const [isAuthLocked, setIsAuthLocked] = useState(false)
|
||||
const isAuthInProgressRef = useRef(false)
|
||||
|
||||
const isInternetReachable = useIsInternetReachable() // boolean | null
|
||||
const [isNetworkChecked, setIsNetworkChecked] = useState(false)
|
||||
@@ -46,20 +47,51 @@ function App() {
|
||||
}
|
||||
}, [isInternetReachable])
|
||||
|
||||
const attemptAuth = useCallback(async () => {
|
||||
if (isAuthInProgressRef.current) {
|
||||
log.trace('[App] attemptAuth skipped, auth already in progress')
|
||||
return
|
||||
}
|
||||
|
||||
isAuthInProgressRef.current = true
|
||||
|
||||
try {
|
||||
const isAuthEnabled = userSettingsStore.isAuthOn
|
||||
log.trace('[App] attemptAuth called')
|
||||
|
||||
const result = await KeyChain.authenticateOnAppStart(isAuthEnabled)
|
||||
log.trace('[App] attemptAuth result:', { success: result.success, shouldExitApp: result.shouldExitApp })
|
||||
|
||||
if (result.success) {
|
||||
setIsUserAuthenticated(true)
|
||||
setIsAuthLocked(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (result.shouldExitApp) {
|
||||
RNExitApp.exitApp()
|
||||
return
|
||||
}
|
||||
|
||||
log.trace('[App] attemptAuth failed, locking app')
|
||||
setIsAuthLocked(true)
|
||||
} catch (e: any) {
|
||||
log.error('[App] attemptAuth caught error', { message: e.message })
|
||||
setIsAuthLocked(true)
|
||||
} finally {
|
||||
isAuthInProgressRef.current = false
|
||||
}
|
||||
}, [userSettingsStore])
|
||||
|
||||
// === Only safe, always-run startup logic inside useInitialRootStore ===
|
||||
const { rehydrated } = useInitialRootStore(() => {
|
||||
log.trace('[App]', 'Root store rehydrated')
|
||||
|
||||
// User authentication (biometrics / PIN)
|
||||
if (userSettingsStore.isAuthOn) {
|
||||
KeyChain.authenticateOnAppStart(userSettingsStore.isAuthOn)
|
||||
.then((result) => {
|
||||
if (result.success) {
|
||||
setIsUserAuthenticated(true)
|
||||
} else if (result.shouldExitApp) {
|
||||
RNExitApp.exitApp()
|
||||
}
|
||||
})
|
||||
// Keep UI in locked mode until authentication succeeds.
|
||||
setIsAuthLocked(true)
|
||||
attemptAuth()
|
||||
} else {
|
||||
setIsUserAuthenticated(true)
|
||||
}
|
||||
@@ -73,7 +105,6 @@ function App() {
|
||||
relaysStore.resetStatuses()
|
||||
})
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isInternetReachable === null ||
|
||||
@@ -126,19 +157,41 @@ function App() {
|
||||
rehydrated
|
||||
])
|
||||
|
||||
// Show splash screen until fully ready
|
||||
// Splash / Locked screen
|
||||
if (
|
||||
!rehydrated ||
|
||||
!isUserAuthenticated ||
|
||||
!isDeviceAuthenticated
|
||||
!isDeviceAuthenticated ||
|
||||
isAuthLocked
|
||||
) {
|
||||
return (
|
||||
<ErrorBoundary catchErrors={Config.catchErrors}>
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Text text={displayName} style={$title} />
|
||||
<Image source={require('../android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png')} />
|
||||
</View>
|
||||
</ErrorBoundary>
|
||||
<View style={{flex: 1}}>
|
||||
<ErrorBoundary catchErrors={Config.catchErrors}>
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Text text={displayName} style={$title} />
|
||||
<Image source={require('../android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png')} />
|
||||
</View>
|
||||
</ErrorBoundary>
|
||||
{isAuthLocked && (
|
||||
<View style={{alignItems: 'center', paddingHorizontal: spacing.large, paddingBottom: 60}}>
|
||||
<RNText style={{color: colors.palette.neutral400, fontSize: 14, textAlign: 'center', marginBottom: spacing.large}}>
|
||||
Minibits is locked. Authentication is required to continue.
|
||||
</RNText>
|
||||
<Pressable
|
||||
style={{backgroundColor: colors.palette.accent400, paddingVertical: spacing.small, paddingHorizontal: spacing.extraLarge, borderRadius: spacing.small, marginBottom: spacing.medium, minWidth: 200, alignItems: 'center'}}
|
||||
onPress={attemptAuth}
|
||||
>
|
||||
<RNText style={{color: 'white', fontSize: 16, fontWeight: '600'}}>Authenticate</RNText>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={{paddingVertical: spacing.small, paddingHorizontal: spacing.extraLarge, minWidth: 200, alignItems: 'center'}}
|
||||
onPress={() => RNExitApp.exitApp()}
|
||||
>
|
||||
<RNText style={{color: colors.palette.neutral400, fontSize: 16}}>Close app</RNText>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -473,13 +473,13 @@
|
||||
"saveContactPrivateDesc": "Save %{nip05} to your Private contacts so you can pay faster",
|
||||
"scanReceiveExtractFail": "Could not extract ecash token nor LNURL withdraw link to receive.",
|
||||
"scanScreen_onPasteEmptyClipboard": "First copy ecash token, invoice, LNURL link or lightning address. Then paste.",
|
||||
"securityScreen_biometricAuth": "Biometric authentication",
|
||||
"securityScreen_biometricAuthDescription": "Require biometric authentication for Minibits to start.",
|
||||
"securityScreen_biometry": "Biometric authentication",
|
||||
"securityScreen_biometryAvailable": "Biometric authentication is now required for Minibits to start.",
|
||||
"securityScreen_biometryNone": "You have not setup biometric authentication or your device does not support it.",
|
||||
"securityScreen_biometricAuth": "Authentication",
|
||||
"securityScreen_biometricAuthDescription": "Require authentication for Minibits to start.",
|
||||
"securityScreen_biometry": "Authentication",
|
||||
"securityScreen_biometryAvailable": "Authentication is now required for Minibits to start.",
|
||||
"securityScreen_biometryNone": "Your device does not support authentication.",
|
||||
"securityScreen_posAuth": "POS mode authentication",
|
||||
"securityScreen_posAuthDescription": "Require biometric authentication when leaving wallet POS mode.",
|
||||
"securityScreen_posAuthDescription": "Require authentication when leaving wallet POS mode.",
|
||||
"securityScreenTitle": "Security",
|
||||
"seedRecoveryMints": "Mints",
|
||||
"seedRecoveryScreenTitle": "Wallet recovery",
|
||||
|
||||
@@ -472,11 +472,13 @@
|
||||
"saveContactPrivateDesc": "Guarda %{nip05} en tus contactos privados para que puedas pagar más rápido",
|
||||
"scanReceiveExtractFail": "No se pudo extraer el token ecash ni el enlace de retiro de LNURL para recibirlo.",
|
||||
"scanScreen_onPasteEmptyClipboard": "Primero, copia el token de ecash, la factura, el enlace LNURL o la dirección Lightning. Luego, pégalo.",
|
||||
"securityScreen_biometricAuth": "Autenticación biométrica",
|
||||
"securityScreen_biometricAuthDescription": "Requerir autenticación biométrica para que Minibits comience.",
|
||||
"securityScreen_biometry": "Autenticación biométrica",
|
||||
"securityScreen_biometryAvailable": "Ahora se requiere autenticación biométrica para iniciar Minibits.",
|
||||
"securityScreen_biometryNone": "No ha configurado la autenticación biométrica o su dispositivo no la admite.",
|
||||
"securityScreen_biometricAuth": "Autenticación",
|
||||
"securityScreen_biometricAuthDescription": "Requerir autenticación para que Minibits comience.",
|
||||
"securityScreen_biometry": "Autenticación",
|
||||
"securityScreen_biometryAvailable": "Ahora se requiere autenticación para iniciar Minibits.",
|
||||
"securityScreen_biometryNone": "Su dispositivo no admite autenticación.",
|
||||
"securityScreen_posAuth": "Autenticación de modo POS",
|
||||
"securityScreen_posAuthDescription": "Requerir autenticación al salir del modo POS.",
|
||||
"securityScreenTitle": "Seguridad",
|
||||
"seedRecoveryMints": "Mentas",
|
||||
"seedRecoveryScreenTitle": "Recuperación de billetera",
|
||||
|
||||
@@ -473,11 +473,13 @@
|
||||
"saveContactPrivateDesc": "Salve %{nip05} em seus contatos privados para pagar mais rápido",
|
||||
"scanReceiveExtractFail": "Não foi possível extrair token ecash nem link de saque LNURL para receber.",
|
||||
"scanScreen_onPasteEmptyClipboard": "Primeiro copie token ecash, invoice, link LNURL ou endereço Lightning. Depois cole.",
|
||||
"securityScreen_biometricAuth": "Autenticação biométrica",
|
||||
"securityScreen_biometricAuthDescription": "Exija autenticação biométrica para iniciar Minibits.",
|
||||
"securityScreen_biometry": "Autenticação biométrica",
|
||||
"securityScreen_biometryAvailable": "Autenticação biométrica agora é necessária para iniciar Minibits.",
|
||||
"securityScreen_biometryNone": "Você não configurou autenticação biométrica ou seu dispositivo não suporta.",
|
||||
"securityScreen_biometricAuth": "Autenticação",
|
||||
"securityScreen_biometricAuthDescription": "Exija autenticação para iniciar Minibits.",
|
||||
"securityScreen_biometry": "Autenticação",
|
||||
"securityScreen_biometryAvailable": "Autenticação agora é necessária para iniciar Minibits.",
|
||||
"securityScreen_biometryNone": "Seu dispositivo não suporta autenticação.",
|
||||
"securityScreen_posAuth": "Autenticação do modo POS",
|
||||
"securityScreen_posAuthDescription": "Exigir autenticação ao sair do modo POS.",
|
||||
"securityScreenTitle": "Segurança",
|
||||
"seedRecoveryMints": "Mints",
|
||||
"seedRecoveryScreenTitle": "Recuperação de carteira",
|
||||
|
||||
@@ -473,11 +473,13 @@
|
||||
"saveContactPrivateDesc": "Ulož %{nip05} do svojich súkromných kontaktov a plať rýchlejšie.",
|
||||
"scanReceiveExtractFail": "Nenašiel sa ecash token ani LNURL výber na prijatie.",
|
||||
"scanScreen_onPasteEmptyClipboard": "Najprv skopíruj ecash token, invoice, LNURL kód, alebo lightning adresu. Potom vlož.",
|
||||
"securityScreen_biometricAuth": "Biometrická autentifikácia",
|
||||
"securityScreen_biometricAuthDescription": "Vyžaduj biometrickú autentifikáciu na vstup do Minibits.",
|
||||
"securityScreen_biometry": "Biometrická autentifikácia",
|
||||
"securityScreen_biometryAvailable": "Biometrická autentifikácia je potrebná na vstup do Minibits.",
|
||||
"securityScreen_biometryNone": "Nenastavil si biometrickú autentifikáciu, alebo ju tvoj mobil nepodporuje.",
|
||||
"securityScreen_biometricAuth": "Autentifikácia",
|
||||
"securityScreen_biometricAuthDescription": "Vyžaduj autentifikáciu na vstup do Minibits.",
|
||||
"securityScreen_biometry": "Autentifikácia",
|
||||
"securityScreen_biometryAvailable": "Autentifikácia je potrebná na vstup do Minibits.",
|
||||
"securityScreen_biometryNone": "Tvoj mobil nepodporuje autentifikáciu.",
|
||||
"securityScreen_posAuth": "POS autentifikácia",
|
||||
"securityScreen_posAuthDescription": "Vyžaduj autentifikáciu pri opúšťaní POS režimu.",
|
||||
"securityScreenTitle": "Bezpečnosť",
|
||||
"seedRecoveryMints": "Minty",
|
||||
"seedRecoveryScreenTitle": "Obnova peňaženky",
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import React, {FC, useEffect, useState} from 'react'
|
||||
import React, {useState} from 'react'
|
||||
import {ScrollView, Switch, TextStyle, View, ViewStyle} from 'react-native'
|
||||
import {colors, spacing, useThemeColor} from '../theme'
|
||||
import {
|
||||
Icon,
|
||||
ListItem,
|
||||
Screen,
|
||||
Text,
|
||||
Card,
|
||||
Loading,
|
||||
ErrorModal,
|
||||
InfoModal,
|
||||
BottomModal,
|
||||
PosIcon,
|
||||
} from '../components'
|
||||
@@ -19,9 +17,6 @@ import {useHeader} from '../utils/useHeader'
|
||||
import {useStores} from '../models'
|
||||
import AppError from '../utils/AppError'
|
||||
import {ResultModalInfo} from './Wallet/ResultModalInfo'
|
||||
import { KeyChain } from '../services'
|
||||
import { BIOMETRY_TYPE } from 'react-native-keychain'
|
||||
import { log } from '../services/logService'
|
||||
import { StaticScreenProps, useNavigation } from '@react-navigation/native'
|
||||
|
||||
type Props = StaticScreenProps<undefined>
|
||||
@@ -34,41 +29,18 @@ export const SecurityScreen = observer(function SecurityScreen({ route }: Props)
|
||||
})
|
||||
|
||||
const {userSettingsStore} = useStores()
|
||||
const [info, setInfo] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isBiometricAuthOn, setIsBiometricAuthOn] = useState<boolean>(
|
||||
userSettingsStore.isAuthOn,
|
||||
)
|
||||
const [biometryType, setBiometryType] = useState<BIOMETRY_TYPE | null>(null)
|
||||
const [error, setError] = useState<AppError | undefined>()
|
||||
const [isAuthModalVisible, setIsAuthModalVisible] = useState<boolean>(false)
|
||||
const [resultMessage, setResultMessage] = useState<string>()
|
||||
|
||||
useEffect(() => {
|
||||
const getBiometry = async () => {
|
||||
const biometry: BIOMETRY_TYPE | null = await KeyChain.getSupportedBiometryType()
|
||||
log.trace('[getBiometry]', {biometry, isAuthOn: userSettingsStore.isAuthOn})
|
||||
setBiometryType(biometry)
|
||||
}
|
||||
|
||||
getBiometry()
|
||||
return () => {}
|
||||
}, [])
|
||||
|
||||
|
||||
const toggleBiometricAuthSwitch = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
// check device has biometric support - disabled for testing
|
||||
if(!isBiometricAuthOn) {
|
||||
const biometryType = await KeyChain.getSupportedBiometryType()
|
||||
|
||||
if(!biometryType) {
|
||||
setInfo('Your device does not support any biometric authentication method.')
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const result = await userSettingsStore.setIsAuthOn(
|
||||
!isBiometricAuthOn,
|
||||
@@ -78,13 +50,13 @@ export const SecurityScreen = observer(function SecurityScreen({ route }: Props)
|
||||
|
||||
if (result === true) {
|
||||
setResultMessage(
|
||||
'Biometric authentication to access the wallet has been turned on.',
|
||||
'Authentication to access the wallet has been turned on.',
|
||||
)
|
||||
toggleAuthModal()
|
||||
return
|
||||
}
|
||||
|
||||
setResultMessage('Biometric authentication has been disabled.')
|
||||
setResultMessage('Authentication has been disabled.')
|
||||
toggleAuthModal()
|
||||
} catch (e: any) {
|
||||
handleError(e)
|
||||
@@ -233,7 +205,6 @@ export const SecurityScreen = observer(function SecurityScreen({ route }: Props)
|
||||
onBackdropPress={toggleAuthModal}
|
||||
/>
|
||||
{error && <ErrorModal error={error} />}
|
||||
{info && <InfoModal message={info} />}
|
||||
</Screen>
|
||||
)
|
||||
})
|
||||
|
||||
+63
-29
@@ -344,7 +344,8 @@ const saveAuthToken = async function (
|
||||
const getAuthToken = async function (isAuthOn: boolean): Promise<string | undefined> {
|
||||
try {
|
||||
const result = await _Keychain.getGenericPassword({
|
||||
service: KeyChainServiceName.BIOMETRIC_AUTH,
|
||||
service: KeyChainServiceName.BIOMETRIC_AUTH,
|
||||
accessControl: isAuthOn ? _Keychain.ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE : undefined,
|
||||
authenticationPrompt: isAuthOn ? {
|
||||
title: 'Please authenticate',
|
||||
subtitle: '',
|
||||
@@ -391,6 +392,39 @@ export type AuthResult = {
|
||||
shouldExitApp: boolean
|
||||
}
|
||||
|
||||
const getAuthErrorContext = (e: any) => {
|
||||
const codeCandidates = [
|
||||
e?.code,
|
||||
e?.params?.code,
|
||||
e?.params?.error?.code,
|
||||
e?.params?.cause?.code,
|
||||
]
|
||||
.filter((value) => value !== undefined && value !== null)
|
||||
.map((value) => String(value))
|
||||
|
||||
const serialized = `${e?.message ?? ''} ${JSON.stringify(e)}`
|
||||
|
||||
const isBackPressed =
|
||||
codeCandidates.includes('10') ||
|
||||
/"code"\s*:\s*10|code:\s*10/.test(serialized)
|
||||
|
||||
const isCancelPressed =
|
||||
codeCandidates.includes('13') ||
|
||||
/"code"\s*:\s*13|code:\s*13/.test(serialized)
|
||||
|
||||
const isIOSCancel = codeCandidates.includes('-128')
|
||||
const isCryptoFailed = serialized.includes('E_CRYPTO_FAILED')
|
||||
|
||||
return {
|
||||
codeCandidates,
|
||||
serialized,
|
||||
isBackPressed,
|
||||
isCancelPressed,
|
||||
isIOSCancel,
|
||||
isCryptoFailed,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Authenticate user on app start
|
||||
@@ -407,28 +441,32 @@ const authenticateOnAppStart = async function (isAuthOn: boolean): Promise<AuthR
|
||||
}
|
||||
|
||||
log.trace('[authenticateOnAppStart]', 'No auth token found')
|
||||
return { success: false, shouldExitApp: false }
|
||||
return { success: false, shouldExitApp: true }
|
||||
} catch (e: any) {
|
||||
// Handle specific error codes for user actions
|
||||
if (e && typeof e === 'object') {
|
||||
const errString = JSON.stringify(e)
|
||||
const isBackPressed = errString.includes('code: 10')
|
||||
const isCancelPressed = errString.includes('code: 13')
|
||||
const isIOSCancel = 'code' in e && String(e.code) === '-128'
|
||||
const authErrorContext = getAuthErrorContext(e)
|
||||
|
||||
if (isBackPressed) {
|
||||
log.trace('[authenticateOnAppStart]', 'User pressed back button')
|
||||
return { success: false, shouldExitApp: true }
|
||||
}
|
||||
log.warn('[authenticateOnAppStart]', 'Auth error caught', {
|
||||
message: e.message,
|
||||
code: e.code,
|
||||
name: e.name,
|
||||
params: e.params,
|
||||
stringified: authErrorContext.serialized,
|
||||
codeCandidates: authErrorContext.codeCandidates,
|
||||
})
|
||||
|
||||
if (isCancelPressed || isIOSCancel) {
|
||||
log.trace('[authenticateOnAppStart]', 'User cancelled authentication')
|
||||
return { success: false, shouldExitApp: true }
|
||||
}
|
||||
if (authErrorContext.isBackPressed) {
|
||||
log.trace('[authenticateOnAppStart]', 'User pressed back button')
|
||||
return { success: false, shouldExitApp: false }
|
||||
}
|
||||
|
||||
if (authErrorContext.isCancelPressed || authErrorContext.isIOSCancel || authErrorContext.isCryptoFailed) {
|
||||
log.trace('[authenticateOnAppStart]', 'User cancelled authentication')
|
||||
return { success: false, shouldExitApp: false }
|
||||
}
|
||||
|
||||
log.warn('[authenticateOnAppStart]', 'Authentication failed', { message: e.message })
|
||||
return { success: false, shouldExitApp: false }
|
||||
return { success: false, shouldExitApp: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,6 +495,7 @@ const authenticatePOSMode = async function (): Promise<boolean> {
|
||||
try {
|
||||
const result = await _Keychain.getGenericPassword({
|
||||
service: KeyChainServiceName.BIOMETRIC_AUTH,
|
||||
accessControl: _Keychain.ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE,
|
||||
authenticationPrompt: {
|
||||
title: 'Authentication required',
|
||||
subtitle: '',
|
||||
@@ -474,21 +513,16 @@ const authenticatePOSMode = async function (): Promise<boolean> {
|
||||
return false
|
||||
} catch (e: any) {
|
||||
// Handle specific error codes for user actions
|
||||
if (e && typeof e === 'object') {
|
||||
const errString = JSON.stringify(e)
|
||||
const isBackPressed = errString.includes('code: 10')
|
||||
const isCancelPressed = errString.includes('code: 13')
|
||||
const isIOSCancel = 'code' in e && String(e.code) === '-128'
|
||||
const authErrorContext = getAuthErrorContext(e)
|
||||
|
||||
if (isBackPressed) {
|
||||
log.trace('[authenticatePOSMode]', 'User pressed back button')
|
||||
return false
|
||||
}
|
||||
if (authErrorContext.isBackPressed) {
|
||||
log.trace('[authenticatePOSMode]', 'User pressed back button')
|
||||
return false
|
||||
}
|
||||
|
||||
if (isCancelPressed || isIOSCancel) {
|
||||
log.trace('[authenticatePOSMode]', 'User cancelled authentication')
|
||||
return false
|
||||
}
|
||||
if (authErrorContext.isCancelPressed || authErrorContext.isIOSCancel || authErrorContext.isCryptoFailed) {
|
||||
log.trace('[authenticatePOSMode]', 'User cancelled authentication')
|
||||
return false
|
||||
}
|
||||
|
||||
log.trace('[authenticatePOSMode]', 'Authentication failed', { message: e.message })
|
||||
|
||||
Reference in New Issue
Block a user