diff --git a/.idea/.gitignore b/.idea/.gitignore index 756c087f..2db4524c 100644 --- a/.idea/.gitignore +++ b/.idea/.gitignore @@ -8,4 +8,5 @@ /copilot/chatSessions # User-specific files /deploymentTargetSelector.xml -/misc.xml \ No newline at end of file +/misc.xml +/androidTestResultsUserPreferences.xml diff --git a/app/src/main/java/chat/stoat/api/StoatAPI.kt b/app/src/main/java/chat/stoat/api/StoatAPI.kt index 8e0085a1..a9175d8e 100644 --- a/app/src/main/java/chat/stoat/api/StoatAPI.kt +++ b/app/src/main/java/chat/stoat/api/StoatAPI.kt @@ -10,6 +10,7 @@ import chat.stoat.api.StoatAPI.initialize import chat.stoat.api.internals.Members import chat.stoat.api.realtime.DisconnectionState import chat.stoat.api.realtime.RealtimeSocket +import chat.stoat.api.routes.account.MFA_TICKET_HEADER_NAME import chat.stoat.api.routes.user.fetchSelf import chat.stoat.api.unreads.Unreads import chat.stoat.core.model.data.STOAT_BASE @@ -106,7 +107,7 @@ val StoatHttp = HttpClient(OkHttp) { val chuckerInterceptor = ChuckerInterceptor.Builder(StoatApplication.instance) .collector(chuckerCollector) .maxContentLength(250_000L) - .redactHeaders(StoatAPI.TOKEN_HEADER_NAME) + .redactHeaders(StoatAPI.TOKEN_HEADER_NAME, MFA_TICKET_HEADER_NAME) .alwaysReadResponseBody(true) .createShortcut(false) .build() diff --git a/app/src/main/java/chat/stoat/api/routes/account/Login.kt b/app/src/main/java/chat/stoat/api/routes/account/Login.kt index f5fdf1eb..a3103a83 100644 --- a/app/src/main/java/chat/stoat/api/routes/account/Login.kt +++ b/app/src/main/java/chat/stoat/api/routes/account/Login.kt @@ -13,9 +13,17 @@ import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.http.contentType +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.PolymorphicKind +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder @Serializable data class LoginNegotiation( @@ -51,17 +59,49 @@ data class LoginMfaAmendmentRecoveryCode( val friendlyName: String ) +/** + * To add a new MFA method, add a variant here plus its branch in [MfaResponseSerializer] + * then describe the UI in [chat.stoat.composables.mfa.MfaMethod]. + */ +@Serializable(with = MfaResponseSerializer::class) +sealed interface MfaResponse + +@Serializable +data class MfaResponsePassword( + val password: String +) : MfaResponse + @Serializable data class MfaResponseRecoveryCode( @SerialName("recovery_code") val recoveryCode: String -) +) : MfaResponse @Serializable data class MfaResponseTotpCode( @SerialName("totp_code") val totpCode: String -) +) : MfaResponse + +object MfaResponseSerializer : KSerializer { + @OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class) + override val descriptor: SerialDescriptor = + buildSerialDescriptor("MfaResponse", PolymorphicKind.SEALED) + + override fun serialize(encoder: Encoder, value: MfaResponse) = when (value) { + is MfaResponsePassword -> + encoder.encodeSerializableValue(MfaResponsePassword.serializer(), value) + + is MfaResponseRecoveryCode -> + encoder.encodeSerializableValue(MfaResponseRecoveryCode.serializer(), value) + + is MfaResponseTotpCode -> + encoder.encodeSerializableValue(MfaResponseTotpCode.serializer(), value) + } + + override fun deserialize(decoder: Decoder): MfaResponse = + throw UnsupportedOperationException("MfaResponse is only ever sent to the API") +} @Serializable data class MfaLoginSpec( diff --git a/app/src/main/java/chat/stoat/api/routes/account/Mfa.kt b/app/src/main/java/chat/stoat/api/routes/account/Mfa.kt new file mode 100644 index 00000000..5d9df316 --- /dev/null +++ b/app/src/main/java/chat/stoat/api/routes/account/Mfa.kt @@ -0,0 +1,97 @@ +package chat.stoat.api.routes.account + +import chat.stoat.api.StoatAPIError +import chat.stoat.api.StoatHttp +import chat.stoat.api.StoatJson +import chat.stoat.api.api +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.patch +import io.ktor.client.request.post +import io.ktor.client.request.put +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +const val MFA_TICKET_HEADER_NAME = "x-mfa-ticket" + +@Serializable +data class MfaTicket( + @SerialName("_id") val id: String, + @SerialName("account_id") val accountId: String, + val token: String, + val validated: Boolean = false, + val authorised: Boolean = false, + @SerialName("last_totp_code") val lastTotpCode: String? = null +) + +@Serializable +private data class TotpSecretResponse(val secret: String) + +private suspend fun HttpResponse.ensureSuccess(what: String) { + if (status.isSuccess()) return + + val body = bodyAsText() + runCatching { StoatJson.decodeFromString(StoatAPIError.serializer(), body) } + .onSuccess { throw Exception(it.type) } + + throw Exception("Failed to $what: $body") +} + +suspend fun fetchMfaMethods(): List { + val res = StoatHttp.get("/auth/mfa/methods".api()) + res.ensureSuccess("fetch MFA methods") + return StoatJson.decodeFromString(res.bodyAsText()) +} + +suspend fun createMfaTicket(response: MfaResponse): MfaTicket { + val res = StoatHttp.put("/auth/mfa/ticket".api()) { + setBody(response) + contentType(ContentType.Application.Json) + } + res.ensureSuccess("create MFA ticket") + return StoatJson.decodeFromString(res.bodyAsText()) +} + +suspend fun disableTotp(mfaTicketToken: String) { + StoatHttp.delete("/auth/mfa/totp".api()) { + header(MFA_TICKET_HEADER_NAME, mfaTicketToken) + }.ensureSuccess("disable TOTP") +} + +suspend fun generateTotpSecret(mfaTicketToken: String): String { + val res = StoatHttp.post("/auth/mfa/totp".api()) { + header(MFA_TICKET_HEADER_NAME, mfaTicketToken) + } + res.ensureSuccess("generate TOTP secret") + return StoatJson.decodeFromString(TotpSecretResponse.serializer(), res.bodyAsText()).secret +} + +suspend fun enableTotp(totpCode: String) { + StoatHttp.put("/auth/mfa/totp".api()) { + setBody(MfaResponseTotpCode(totpCode)) + contentType(ContentType.Application.Json) + }.ensureSuccess("enable TOTP") +} + +suspend fun fetchRecoveryCodes(mfaTicketToken: String): List { + val res = StoatHttp.post("/auth/mfa/recovery".api()) { + header(MFA_TICKET_HEADER_NAME, mfaTicketToken) + } + res.ensureSuccess("fetch recovery codes") + return StoatJson.decodeFromString(res.bodyAsText()) +} + +suspend fun regenerateRecoveryCodes(mfaTicketToken: String): List { + val res = StoatHttp.patch("/auth/mfa/recovery".api()) { + header(MFA_TICKET_HEADER_NAME, mfaTicketToken) + } + res.ensureSuccess("regenerate recovery codes") + return StoatJson.decodeFromString(res.bodyAsText()) +} diff --git a/app/src/main/java/chat/stoat/composables/mfa/MfaMethod.kt b/app/src/main/java/chat/stoat/composables/mfa/MfaMethod.kt new file mode 100644 index 00000000..6b393e70 --- /dev/null +++ b/app/src/main/java/chat/stoat/composables/mfa/MfaMethod.kt @@ -0,0 +1,75 @@ +package chat.stoat.composables.mfa + +import androidx.annotation.StringRes +import androidx.compose.ui.autofill.ContentType +import androidx.compose.ui.text.input.KeyboardType +import chat.stoat.R +import chat.stoat.api.routes.account.MfaResponse +import chat.stoat.api.routes.account.MfaResponsePassword +import chat.stoat.api.routes.account.MfaResponseRecoveryCode +import chat.stoat.api.routes.account.MfaResponseTotpCode + +enum class MfaMethod( + val apiName: String, + @param:StringRes val label: Int, + @param:StringRes val lead: Int, + @param:StringRes val inputLabel: Int, + val keyboardType: KeyboardType, + val secureInput: Boolean, + val monospaceInput: Boolean, + val autofillContentType: ContentType?, + /** Submit without pressing confirm as soon as [isSubmittable] returns true */ + val autoSubmit: Boolean, + val sanitizeInput: (String) -> String, + val isSubmittable: (String) -> Boolean, + val buildResponse: (String) -> MfaResponse +) { + Totp( + apiName = "Totp", + label = R.string.mfa_method_totp, + lead = R.string.mfa_totp_lead, + inputLabel = R.string.mfa_totp_code, + keyboardType = KeyboardType.Number, + secureInput = false, + monospaceInput = true, + autofillContentType = null, + autoSubmit = true, + sanitizeInput = { input -> input.filter(Char::isDigit).take(6) }, + isSubmittable = { it.length == 6 }, + buildResponse = { MfaResponseTotpCode(it) } + ), + + Password( + apiName = "Password", + label = R.string.mfa_method_password, + lead = R.string.mfa_password_lead, + inputLabel = R.string.mfa_method_password, + keyboardType = KeyboardType.Password, + secureInput = true, + monospaceInput = false, + autofillContentType = ContentType.Password, + autoSubmit = false, + sanitizeInput = { it }, + isSubmittable = { it.isNotEmpty() }, + buildResponse = { MfaResponsePassword(it) } + ), + + Recovery( + apiName = "Recovery", + label = R.string.mfa_method_recovery, + lead = R.string.mfa_recovery_lead, + inputLabel = R.string.mfa_recovery_code, + keyboardType = KeyboardType.Text, + secureInput = false, + monospaceInput = true, + autofillContentType = null, + autoSubmit = false, + sanitizeInput = { it.trim() }, + isSubmittable = { it.isNotBlank() }, + buildResponse = { MfaResponseRecoveryCode(it) } + ); + + companion object { + fun fromApiName(name: String): MfaMethod? = entries.firstOrNull { it.apiName == name } + } +} diff --git a/app/src/main/java/chat/stoat/composables/mfa/MfaPromptSheet.kt b/app/src/main/java/chat/stoat/composables/mfa/MfaPromptSheet.kt new file mode 100644 index 00000000..b36e6785 --- /dev/null +++ b/app/src/main/java/chat/stoat/composables/mfa/MfaPromptSheet.kt @@ -0,0 +1,323 @@ +package chat.stoat.composables.mfa + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SecureTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.stoat.R +import chat.stoat.composables.generic.NonIdealState +import chat.stoat.ui.theme.FragmentMono + +@Composable +fun mfaErrorText(errorType: String?): String = when (errorType) { + null -> "" + "InvalidCredentials" -> stringResource(R.string.mfa_error_invalid_credentials) + "InvalidToken" -> stringResource(R.string.mfa_error_invalid_token) + "DisallowedMfaMethod" -> stringResource(R.string.mfa_error_disallowed_method) + else -> stringResource(R.string.mfa_error_generic, errorType) +} + +private enum class MfaPromptStage { Loading, LoadError, NoMethods, Form } + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun MfaPromptSheet(state: MfaPromptState) { + val session = state.session ?: return + + ModalBottomSheet( + onDismissRequest = { session.cancel() }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + sheetGesturesEnabled = !session.busy + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp) + .padding(bottom = 24.dp) + .imePadding() + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .background( + MaterialTheme.colorScheme.primaryContainer, + MaterialTheme.shapes.large + ) + .size(96.dp) + ) { + Icon( + painter = painterResource(R.drawable.ic_shield_lock_24dp), + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + + Spacer(Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.mfa_prompt_title), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(4.dp)) + + Text( + text = stringResource(R.string.mfa_prompt_lead), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(16.dp)) + + val stage = when { + session.loadError != null -> MfaPromptStage.LoadError + session.methods == null -> MfaPromptStage.Loading + session.methods?.isEmpty() == true -> MfaPromptStage.NoMethods + else -> MfaPromptStage.Form + } + + AnimatedContent( + targetState = stage, + modifier = Modifier.fillMaxWidth() + ) { currentStage -> + when (currentStage) { + MfaPromptStage.Loading -> Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + ) { + LoadingIndicator(modifier = Modifier.size(56.dp)) + } + + MfaPromptStage.LoadError -> NonIdealState( + icon = { + Icon( + painter = painterResource(R.drawable.ic_error_24dp), + contentDescription = null, + modifier = Modifier.size(it) + ) + }, + title = { Text(stringResource(R.string.mfa_prompt_load_error_title)) }, + description = { + Text(stringResource(R.string.mfa_prompt_load_error_description)) + }, + actions = { + TextButton(onClick = { session.cancel() }) { + Text(stringResource(R.string.cancel)) + } + Button(onClick = { session.retryLoad() }) { + Text(stringResource(R.string.mfa_prompt_retry)) + } + } + ) + + MfaPromptStage.NoMethods -> NonIdealState( + icon = { + Icon( + painter = painterResource(R.drawable.ic_lock_24dp), + contentDescription = null, + modifier = Modifier.size(it) + ) + }, + title = { Text(stringResource(R.string.mfa_prompt_no_methods_title)) }, + description = { + Text(stringResource(R.string.mfa_prompt_no_methods_description)) + }, + actions = { + Button(onClick = { session.cancel() }) { + Text(stringResource(R.string.mfa_prompt_close)) + } + } + ) + + MfaPromptStage.Form -> MfaPromptForm(session) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun MfaPromptForm(session: MfaPromptSession) { + val methods = session.methods.orEmpty() + val selected = session.selected ?: return + + Column(modifier = Modifier.fillMaxWidth()) { + if (methods.size > 1) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + ) { + methods.forEach { method -> + FilterChip( + selected = method == selected, + onClick = { session.selectMethod(method) }, + label = { Text(stringResource(method.label)) }, + enabled = !session.busy + ) + } + } + + Spacer(Modifier.height(8.dp)) + } + + AnimatedContent(targetState = selected) { method -> + Column { + Text( + text = stringResource(method.lead), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = 8.dp) + ) + + MfaMethodInput(session, method) + } + } + + AnimatedContent(targetState = session.error) { error -> + if (error != null) { + Text( + text = mfaErrorText(error), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 8.dp) + ) + } + } + + Spacer(Modifier.height(16.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + TextButton( + onClick = { session.cancel() }, + enabled = !session.busy + ) { + Text(stringResource(R.string.cancel)) + } + + Button( + onClick = { session.submit() }, + enabled = !session.busy && + selected.isSubmittable(session.input.text.toString()) + ) { + AnimatedContent(targetState = session.busy) { busy -> + if (busy) { + LoadingIndicator(modifier = Modifier.size(24.dp)) + } else { + Text(stringResource(R.string.mfa_prompt_confirm)) + } + } + } + } + } +} + +@Composable +private fun MfaMethodInput(session: MfaPromptSession, method: MfaMethod) { + val inputModifier = Modifier + .fillMaxWidth() + .semantics { method.autofillContentType?.let { contentType = it } } + + if (method.secureInput) { + SecureTextField( + state = session.input, + label = { Text(stringResource(method.inputLabel)) }, + enabled = !session.busy, + onKeyboardAction = { session.submit() }, + modifier = inputModifier + ) + } else { + val transformation = remember(method) { + InputTransformation { + val current = asCharSequence().toString() + val sanitized = method.sanitizeInput(current) + if (sanitized != current) replace(0, length, sanitized) + } + } + + TextField( + state = session.input, + label = { Text(stringResource(method.inputLabel)) }, + inputTransformation = transformation, + lineLimits = TextFieldLineLimits.SingleLine, + keyboardOptions = KeyboardOptions( + keyboardType = method.keyboardType, + imeAction = ImeAction.Done + ), + onKeyboardAction = { session.submit() }, + textStyle = if (method.monospaceInput) { + LocalTextStyle.current.copy( + fontFamily = FragmentMono, + letterSpacing = 2.sp + ) + } else { + LocalTextStyle.current + }, + enabled = !session.busy, + modifier = inputModifier + ) + } + + if (method.autoSubmit) { + LaunchedEffect(session, method) { + snapshotFlow { session.input.text.toString() } + .collect { text -> + if (!session.busy && method.isSubmittable(text)) session.submit() + } + } + } +} diff --git a/app/src/main/java/chat/stoat/composables/mfa/MfaPromptState.kt b/app/src/main/java/chat/stoat/composables/mfa/MfaPromptState.kt new file mode 100644 index 00000000..6b9433f2 --- /dev/null +++ b/app/src/main/java/chat/stoat/composables/mfa/MfaPromptState.kt @@ -0,0 +1,124 @@ +package chat.stoat.composables.mfa + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import chat.stoat.api.routes.account.MfaResponse +import chat.stoat.api.routes.account.MfaTicket +import chat.stoat.api.routes.account.createMfaTicket +import chat.stoat.api.routes.account.fetchMfaMethods +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +@Stable +class MfaPromptState { + var session by mutableStateOf(null) + private set + + private val mutex = Mutex() + + suspend fun request(): MfaTicket? = mutex.withLock { + val session = MfaPromptSession() + this.session = session + try { + session.loadMethods() + session.awaitTicket() + } finally { + this.session = null + } + } +} + +/** Only use this when you do not have a view model, otherwise prefer [MfaPromptState] */ +@Composable +fun rememberMfaPrompt(): MfaPromptState = remember { MfaPromptState() } + +internal sealed interface MfaPromptEvent { + data class Submit(val response: MfaResponse) : MfaPromptEvent + data object RetryLoad : MfaPromptEvent +} + +@Stable +class MfaPromptSession internal constructor() { + var methods by mutableStateOf?>(null) + private set + var loadError by mutableStateOf(null) + private set + var selected by mutableStateOf(null) + private set + val input = TextFieldState() + var busy by mutableStateOf(false) + internal set + + var error by mutableStateOf(null) + internal set + + // how many various debounce techniques do we have in the codebase at this point? + private val events = Channel(Channel.RENDEZVOUS) + + fun selectMethod(method: MfaMethod) { + if (busy || method == selected) return + selected = method + input.clearText() + error = null + } + + fun submit() { + val method = selected ?: return + val text = input.text.toString() + if (busy || !method.isSubmittable(text)) return + events.trySend(MfaPromptEvent.Submit(method.buildResponse(text))) + } + + fun retryLoad() { + if (!busy) events.trySend(MfaPromptEvent.RetryLoad) + } + + fun cancel() { + if (!busy) events.close() + } + + internal suspend fun awaitTicket(): MfaTicket? { + while (true) { + when (val event = events.receiveCatching().getOrNull() ?: return null) { + MfaPromptEvent.RetryLoad -> loadMethods() + + is MfaPromptEvent.Submit -> { + busy = true + error = null + try { + return createMfaTicket(event.response) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + error = e.message + } finally { + busy = false + } + } + } + } + } + + internal suspend fun loadMethods() { + methods = null + loadError = null + try { + val available = fetchMfaMethods() + val known = available.mapNotNull(MfaMethod::fromApiName).sortedBy { it.ordinal } + methods = known + selected = known.firstOrNull() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + loadError = e.message + } + } +} diff --git a/app/src/main/java/chat/stoat/composables/mfa/RecoveryCodesDialog.kt b/app/src/main/java/chat/stoat/composables/mfa/RecoveryCodesDialog.kt new file mode 100644 index 00000000..dd050e58 --- /dev/null +++ b/app/src/main/java/chat/stoat/composables/mfa/RecoveryCodesDialog.kt @@ -0,0 +1,91 @@ +package chat.stoat.composables.mfa + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import chat.stoat.R +import chat.stoat.internals.Platform +import chat.stoat.ui.theme.FragmentMono + +@Composable +fun RecoveryCodesDialog(codes: List, onDismiss: () -> Unit) { + val clipboardManager = LocalClipboardManager.current + val context = LocalContext.current + val resources = LocalResources.current + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.settings_mfa_recovery_dialog_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text(stringResource(R.string.settings_mfa_recovery_dialog_hint)) + + SelectionContainer { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(16.dp) + ) { + codes.chunked(2).forEach { row -> + Row(modifier = Modifier.fillMaxWidth()) { + row.forEach { code -> + Text( + text = code, + style = MaterialTheme.typography.bodyLarge.copy( + fontFamily = FragmentMono + ), + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f) + ) + } + if (row.size == 1) Spacer(Modifier.weight(1f)) + } + } + } + } + } + }, + confirmButton = { + TextButton(onClick = { + clipboardManager.setText(AnnotatedString(codes.joinToString("\n"))) + if (Platform.needsShowClipboardNotification()) { + Toast.makeText( + context, + resources.getString(R.string.copied), + Toast.LENGTH_SHORT + ).show() + } + }) { + Text(stringResource(R.string.settings_mfa_recovery_copy)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.settings_mfa_recovery_dialog_done)) + } + } + ) +} diff --git a/app/src/main/java/chat/stoat/composables/vectorassets/MfaOff.kt b/app/src/main/java/chat/stoat/composables/vectorassets/MfaOff.kt new file mode 100644 index 00000000..32ad93c5 --- /dev/null +++ b/app/src/main/java/chat/stoat/composables/vectorassets/MfaOff.kt @@ -0,0 +1,151 @@ +package chat.stoat.composables.vectorassets + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val MfaOff: ImageVector + @Composable + get() { + if (_MfaOff != null) { + return _MfaOff!! + } + _MfaOff = ImageVector.Builder( + name = "MfaOff", + defaultWidth = 399.dp, + defaultHeight = 321.dp, + viewportWidth = 399f, + viewportHeight = 321f + ).apply { + path(fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer)) { + moveTo(30f, 0f) + lineTo(368.49f, 0f) + arcTo(30f, 30f, 0f, isMoreThanHalf = false, isPositiveArc = true, 398.49f, 30f) + lineTo(398.49f, 290.14f) + arcTo(30f, 30f, 0f, isMoreThanHalf = false, isPositiveArc = true, 368.49f, 320.14f) + lineTo(30f, 320.14f) + arcTo(30f, 30f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0f, 290.14f) + lineTo(0f, 30f) + arcTo(30f, 30f, 0f, isMoreThanHalf = false, isPositiveArc = true, 30f, 0f) + close() + } + path(fill = SolidColor(MaterialTheme.colorScheme.surfaceContainerHigh)) { + moveTo(60.61f, 225.32f) + curveTo(60.61f, 218.69f, 65.99f, 213.32f, 72.61f, 213.32f) + horizontalLineTo(100.73f) + curveTo(102.94f, 213.32f, 104.73f, 215.11f, 104.73f, 217.32f) + verticalLineTo(262.76f) + curveTo(104.73f, 264.97f, 102.94f, 266.76f, 100.73f, 266.76f) + horizontalLineTo(72.61f) + curveTo(65.99f, 266.76f, 60.61f, 261.39f, 60.61f, 254.76f) + verticalLineTo(225.32f) + close() + } + path(fill = SolidColor(MaterialTheme.colorScheme.surfaceContainerHigh)) { + moveTo(111.24f, 213.32f) + lineTo(147.36f, 213.32f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 151.36f, 217.32f) + lineTo(151.36f, 262.76f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 147.36f, 266.76f) + lineTo(111.24f, 266.76f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 107.24f, 262.76f) + lineTo(107.24f, 217.32f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 111.24f, 213.32f) + close() + } + path(fill = SolidColor(MaterialTheme.colorScheme.surfaceContainerHigh)) { + moveTo(157.88f, 213.32f) + lineTo(193.99f, 213.32f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 197.99f, 217.32f) + lineTo(197.99f, 262.76f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 193.99f, 266.76f) + lineTo(157.88f, 266.76f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 153.88f, 262.76f) + lineTo(153.88f, 217.32f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 157.88f, 213.32f) + close() + } + path(fill = SolidColor(MaterialTheme.colorScheme.surfaceContainerHigh)) { + moveTo(204.5f, 213.32f) + lineTo(240.62f, 213.32f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 244.62f, 217.32f) + lineTo(244.62f, 262.76f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 240.62f, 266.76f) + lineTo(204.5f, 266.76f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 200.5f, 262.76f) + lineTo(200.5f, 217.32f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 204.5f, 213.32f) + close() + } + path(fill = SolidColor(MaterialTheme.colorScheme.surfaceContainerHigh)) { + moveTo(251.13f, 213.32f) + lineTo(287.25f, 213.32f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 291.25f, 217.32f) + lineTo(291.25f, 262.76f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 287.25f, 266.76f) + lineTo(251.13f, 266.76f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 247.13f, 262.76f) + lineTo(247.13f, 217.32f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 251.13f, 213.32f) + close() + } + path(fill = SolidColor(MaterialTheme.colorScheme.surfaceContainerHigh)) { + moveTo(293.77f, 217.32f) + curveTo(293.77f, 215.11f, 295.56f, 213.32f, 297.77f, 213.32f) + horizontalLineTo(325.88f) + curveTo(332.51f, 213.32f, 337.88f, 218.69f, 337.88f, 225.32f) + verticalLineTo(254.76f) + curveTo(337.88f, 261.39f, 332.51f, 266.76f, 325.88f, 266.76f) + horizontalLineTo(297.77f) + curveTo(295.56f, 266.76f, 293.77f, 264.97f, 293.77f, 262.76f) + verticalLineTo(217.32f) + close() + } + path(fill = SolidColor(MaterialTheme.colorScheme.error)) { + moveTo(198.83f, 129.32f) + curveTo(200.7f, 127.45f, 201.63f, 125.2f, 201.63f, 122.57f) + curveTo(201.63f, 119.95f, 200.7f, 117.7f, 198.83f, 115.83f) + curveTo(196.96f, 113.96f, 194.71f, 113.03f, 192.09f, 113.03f) + curveTo(189.46f, 113.03f, 187.21f, 113.96f, 185.34f, 115.83f) + curveTo(183.47f, 117.7f, 182.54f, 119.95f, 182.54f, 122.57f) + curveTo(182.54f, 125.2f, 183.47f, 127.45f, 185.34f, 129.32f) + curveTo(187.21f, 131.19f, 189.46f, 132.12f, 192.09f, 132.12f) + curveTo(194.71f, 132.12f, 196.96f, 131.19f, 198.83f, 129.32f) + close() + moveTo(163.44f, 155.99f) + curveTo(160.82f, 155.99f, 158.57f, 155.06f, 156.7f, 153.19f) + curveTo(154.83f, 151.32f, 153.89f, 149.07f, 153.89f, 146.44f) + verticalLineTo(98.7f) + curveTo(153.89f, 96.08f, 154.83f, 93.83f, 156.7f, 91.96f) + curveTo(158.57f, 90.09f, 160.82f, 89.15f, 163.44f, 89.15f) + horizontalLineTo(196.86f) + verticalLineTo(79.61f) + curveTo(196.86f, 73f, 199.19f, 67.37f, 203.84f, 62.72f) + curveTo(208.49f, 58.06f, 214.12f, 55.74f, 220.73f, 55.74f) + curveTo(227.33f, 55.74f, 232.96f, 58.06f, 237.62f, 62.72f) + curveTo(242.27f, 67.37f, 244.6f, 73f, 244.6f, 79.61f) + horizontalLineTo(235.05f) + curveTo(235.05f, 75.63f, 233.66f, 72.25f, 230.87f, 69.46f) + curveTo(228.09f, 66.68f, 224.71f, 65.29f, 220.73f, 65.29f) + curveTo(216.75f, 65.29f, 213.37f, 66.68f, 210.58f, 69.46f) + curveTo(207.8f, 72.25f, 206.41f, 75.63f, 206.41f, 79.61f) + verticalLineTo(89.15f) + horizontalLineTo(220.73f) + curveTo(223.35f, 89.15f, 225.6f, 90.09f, 227.47f, 91.96f) + curveTo(229.34f, 93.83f, 230.28f, 96.08f, 230.28f, 98.7f) + verticalLineTo(146.44f) + curveTo(230.28f, 149.07f, 229.34f, 151.32f, 227.47f, 153.19f) + curveTo(225.6f, 155.06f, 223.35f, 155.99f, 220.73f, 155.99f) + horizontalLineTo(163.44f) + close() + } + }.build() + + return _MfaOff!! + } + +@Suppress("ObjectPropertyName") +private var _MfaOff: ImageVector? = null diff --git a/app/src/main/java/chat/stoat/composables/vectorassets/MfaOn.kt b/app/src/main/java/chat/stoat/composables/vectorassets/MfaOn.kt new file mode 100644 index 00000000..a04f62b5 --- /dev/null +++ b/app/src/main/java/chat/stoat/composables/vectorassets/MfaOn.kt @@ -0,0 +1,294 @@ +package chat.stoat.composables.vectorassets + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val MfaOn: ImageVector + @Composable + get() { + if (_MfaOn != null) { + return _MfaOn!! + } + _MfaOn = ImageVector.Builder( + name = "MfaOn", + defaultWidth = 399.dp, + defaultHeight = 321.dp, + viewportWidth = 399f, + viewportHeight = 321f + ).apply { + path(fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer)) { + moveTo(30f, 0f) + lineTo(368.49f, 0f) + arcTo(30f, 30f, 0f, isMoreThanHalf = false, isPositiveArc = true, 398.49f, 30f) + lineTo(398.49f, 290.14f) + arcTo(30f, 30f, 0f, isMoreThanHalf = false, isPositiveArc = true, 368.49f, 320.14f) + lineTo(30f, 320.14f) + arcTo(30f, 30f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0f, 290.14f) + lineTo(0f, 30f) + arcTo(30f, 30f, 0f, isMoreThanHalf = false, isPositiveArc = true, 30f, 0f) + close() + } + path(fill = SolidColor(MaterialTheme.colorScheme.surfaceContainerHigh)) { + moveTo(60.61f, 225.32f) + curveTo(60.61f, 218.69f, 65.99f, 213.32f, 72.61f, 213.32f) + horizontalLineTo(100.73f) + curveTo(102.94f, 213.32f, 104.73f, 215.11f, 104.73f, 217.32f) + verticalLineTo(262.76f) + curveTo(104.73f, 264.97f, 102.94f, 266.76f, 100.73f, 266.76f) + horizontalLineTo(72.61f) + curveTo(65.99f, 266.76f, 60.61f, 261.39f, 60.61f, 254.76f) + verticalLineTo(225.32f) + close() + } + path(fill = SolidColor(MaterialTheme.colorScheme.onSurface)) { + moveTo(82.67f, 240.04f) + moveToRelative(-10.23f, 0f) + arcToRelative( + 10.23f, + 10.23f, + 0f, + isMoreThanHalf = true, + isPositiveArc = true, + 20.47f, + 0f + ) + arcToRelative( + 10.23f, + 10.23f, + 0f, + isMoreThanHalf = true, + isPositiveArc = true, + -20.47f, + 0f + ) + } + path(fill = SolidColor(MaterialTheme.colorScheme.surfaceContainerHigh)) { + moveTo(111.24f, 213.32f) + lineTo(147.36f, 213.32f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 151.36f, 217.32f) + lineTo(151.36f, 262.76f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 147.36f, 266.76f) + lineTo(111.24f, 266.76f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 107.24f, 262.76f) + lineTo(107.24f, 217.32f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 111.24f, 213.32f) + close() + } + path(fill = SolidColor(MaterialTheme.colorScheme.onSurface)) { + moveTo(129.3f, 240.04f) + moveToRelative(-10.23f, 0f) + arcToRelative( + 10.23f, + 10.23f, + 0f, + isMoreThanHalf = true, + isPositiveArc = true, + 20.47f, + 0f + ) + arcToRelative( + 10.23f, + 10.23f, + 0f, + isMoreThanHalf = true, + isPositiveArc = true, + -20.47f, + 0f + ) + } + path(fill = SolidColor(MaterialTheme.colorScheme.surfaceContainerHigh)) { + moveTo(157.88f, 213.32f) + lineTo(193.99f, 213.32f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 197.99f, 217.32f) + lineTo(197.99f, 262.76f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 193.99f, 266.76f) + lineTo(157.88f, 266.76f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 153.88f, 262.76f) + lineTo(153.88f, 217.32f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 157.88f, 213.32f) + close() + } + path(fill = SolidColor(MaterialTheme.colorScheme.onSurface)) { + moveTo(175.93f, 240.04f) + moveToRelative(-10.23f, 0f) + arcToRelative( + 10.23f, + 10.23f, + 0f, + isMoreThanHalf = true, + isPositiveArc = true, + 20.47f, + 0f + ) + arcToRelative( + 10.23f, + 10.23f, + 0f, + isMoreThanHalf = true, + isPositiveArc = true, + -20.47f, + 0f + ) + } + path(fill = SolidColor(MaterialTheme.colorScheme.surfaceContainerHigh)) { + moveTo(204.5f, 213.32f) + lineTo(240.62f, 213.32f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 244.62f, 217.32f) + lineTo(244.62f, 262.76f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 240.62f, 266.76f) + lineTo(204.5f, 266.76f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 200.5f, 262.76f) + lineTo(200.5f, 217.32f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 204.5f, 213.32f) + close() + } + path(fill = SolidColor(MaterialTheme.colorScheme.onSurface)) { + moveTo(222.56f, 240.04f) + moveToRelative(-10.23f, 0f) + arcToRelative( + 10.23f, + 10.23f, + 0f, + isMoreThanHalf = true, + isPositiveArc = true, + 20.47f, + 0f + ) + arcToRelative( + 10.23f, + 10.23f, + 0f, + isMoreThanHalf = true, + isPositiveArc = true, + -20.47f, + 0f + ) + } + path(fill = SolidColor(MaterialTheme.colorScheme.surfaceContainerHigh)) { + moveTo(251.13f, 213.32f) + lineTo(287.25f, 213.32f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 291.25f, 217.32f) + lineTo(291.25f, 262.76f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 287.25f, 266.76f) + lineTo(251.13f, 266.76f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 247.13f, 262.76f) + lineTo(247.13f, 217.32f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 251.13f, 213.32f) + close() + } + path(fill = SolidColor(MaterialTheme.colorScheme.onSurface)) { + moveTo(269.19f, 240.04f) + moveToRelative(-10.23f, 0f) + arcToRelative( + 10.23f, + 10.23f, + 0f, + isMoreThanHalf = true, + isPositiveArc = true, + 20.47f, + 0f + ) + arcToRelative( + 10.23f, + 10.23f, + 0f, + isMoreThanHalf = true, + isPositiveArc = true, + -20.47f, + 0f + ) + } + path(fill = SolidColor(MaterialTheme.colorScheme.surfaceContainerHigh)) { + moveTo(293.77f, 217.32f) + curveTo(293.77f, 215.11f, 295.56f, 213.32f, 297.77f, 213.32f) + horizontalLineTo(325.88f) + curveTo(332.51f, 213.32f, 337.88f, 218.69f, 337.88f, 225.32f) + verticalLineTo(254.76f) + curveTo(337.88f, 261.39f, 332.51f, 266.76f, 325.88f, 266.76f) + horizontalLineTo(297.77f) + curveTo(295.56f, 266.76f, 293.77f, 264.97f, 293.77f, 262.76f) + verticalLineTo(217.32f) + close() + } + path(fill = SolidColor(MaterialTheme.colorScheme.onSurface)) { + moveTo(315.82f, 240.04f) + moveToRelative(-10.23f, 0f) + arcToRelative( + 10.23f, + 10.23f, + 0f, + isMoreThanHalf = true, + isPositiveArc = true, + 20.47f, + 0f + ) + arcToRelative( + 10.23f, + 10.23f, + 0f, + isMoreThanHalf = true, + isPositiveArc = true, + -20.47f, + 0f + ) + } + path(fill = SolidColor(MaterialTheme.colorScheme.primary)) { + moveTo(197.91f, 157.26f) + curveTo(186.63f, 154.42f, 177.31f, 147.95f, 169.96f, 137.84f) + curveTo(162.61f, 127.73f, 158.94f, 116.5f, 158.94f, 104.16f) + verticalLineTo(74.43f) + lineTo(197.91f, 59.82f) + lineTo(236.89f, 74.43f) + verticalLineTo(104.16f) + curveTo(236.89f, 104.97f, 236.87f, 105.78f, 236.83f, 106.59f) + curveTo(236.79f, 107.4f, 236.73f, 108.22f, 236.65f, 109.03f) + curveTo(235.92f, 108.87f, 235.17f, 108.74f, 234.4f, 108.66f) + curveTo(233.63f, 108.58f, 232.83f, 108.54f, 232.02f, 108.54f) + curveTo(225.28f, 108.54f, 219.54f, 110.9f, 214.79f, 115.61f) + curveTo(210.03f, 120.32f, 207.66f, 126.08f, 207.66f, 132.9f) + verticalLineTo(153.61f) + curveTo(206.12f, 154.42f, 204.53f, 155.13f, 202.91f, 155.74f) + curveTo(201.28f, 156.35f, 199.62f, 156.86f, 197.91f, 157.26f) + close() + moveTo(221.54f, 157.26f) + curveTo(220.41f, 157.26f, 219.43f, 156.86f, 218.62f, 156.05f) + curveTo(217.81f, 155.24f, 217.4f, 154.26f, 217.4f, 153.12f) + verticalLineTo(137.04f) + curveTo(217.4f, 135.91f, 217.81f, 134.93f, 218.62f, 134.12f) + curveTo(219.43f, 133.31f, 220.41f, 132.9f, 221.54f, 132.9f) + horizontalLineTo(222.28f) + verticalLineTo(128.03f) + curveTo(222.28f, 125.35f, 223.23f, 123.06f, 225.14f, 121.15f) + curveTo(227.05f, 119.24f, 229.34f, 118.29f, 232.02f, 118.29f) + curveTo(234.7f, 118.29f, 236.99f, 119.24f, 238.9f, 121.15f) + curveTo(240.81f, 123.06f, 241.77f, 125.35f, 241.77f, 128.03f) + verticalLineTo(132.9f) + horizontalLineTo(242.5f) + curveTo(243.63f, 132.9f, 244.61f, 133.31f, 245.42f, 134.12f) + curveTo(246.23f, 134.93f, 246.64f, 135.91f, 246.64f, 137.04f) + verticalLineTo(153.12f) + curveTo(246.64f, 154.26f, 246.23f, 155.24f, 245.42f, 156.05f) + curveTo(244.61f, 156.86f, 243.63f, 157.26f, 242.5f, 157.26f) + horizontalLineTo(221.54f) + close() + moveTo(227.15f, 132.9f) + horizontalLineTo(236.89f) + verticalLineTo(128.03f) + curveTo(236.89f, 126.65f, 236.43f, 125.49f, 235.49f, 124.56f) + curveTo(234.56f, 123.63f, 233.4f, 123.16f, 232.02f, 123.16f) + curveTo(230.64f, 123.16f, 229.48f, 123.63f, 228.55f, 124.56f) + curveTo(227.62f, 125.49f, 227.15f, 126.65f, 227.15f, 128.03f) + verticalLineTo(132.9f) + close() + } + }.build() + + return _MfaOn!! + } + +@Suppress("ObjectPropertyName") +private var _MfaOn: ImageVector? = null diff --git a/app/src/main/java/chat/stoat/di/ViewModelModule.kt b/app/src/main/java/chat/stoat/di/ViewModelModule.kt index 57d2c658..7be578ff 100644 --- a/app/src/main/java/chat/stoat/di/ViewModelModule.kt +++ b/app/src/main/java/chat/stoat/di/ViewModelModule.kt @@ -9,6 +9,7 @@ import chat.stoat.screens.login.MfaScreenViewModel import chat.stoat.screens.settings.AccountSettingsScreenViewModel import chat.stoat.screens.settings.AppearanceSettingsScreenViewModel import chat.stoat.screens.settings.DebugSettingsScreenViewModel +import chat.stoat.screens.settings.MfaSettingsScreenViewModel import chat.stoat.screens.settings.NotificationsSettingsScreenViewModel import chat.stoat.screens.settings.ProfileSettingsScreenViewModel import chat.stoat.screens.settings.SettingsScreenViewModel @@ -34,4 +35,5 @@ val viewModelModule = module { viewModel { AppearanceSettingsScreenViewModel(androidApplication()) } viewModel { ChannelSettingsOverviewViewModel(androidApplication()) } viewModel { AccountSettingsScreenViewModel(androidApplication()) } + viewModel { MfaSettingsScreenViewModel(androidApplication()) } } \ No newline at end of file diff --git a/app/src/main/java/chat/stoat/screens/settings/AccountSettingsScreen.kt b/app/src/main/java/chat/stoat/screens/settings/AccountSettingsScreen.kt index b7fb2225..933be0f5 100644 --- a/app/src/main/java/chat/stoat/screens/settings/AccountSettingsScreen.kt +++ b/app/src/main/java/chat/stoat/screens/settings/AccountSettingsScreen.kt @@ -435,7 +435,7 @@ fun AccountSettingsScreen( } Spacer(modifier = Modifier.width(16.dp)) Switch( - checked = viewModel.mfaState?.totpMfa == true, + checked = viewModel.mfaState?.totpMfa == true || viewModel.mfaState?.securityKeyMfa == true, onCheckedChange = null ) } diff --git a/app/src/main/java/chat/stoat/screens/settings/MfaSettingsScreen.kt b/app/src/main/java/chat/stoat/screens/settings/MfaSettingsScreen.kt index c911be18..b8b7dec4 100644 --- a/app/src/main/java/chat/stoat/screens/settings/MfaSettingsScreen.kt +++ b/app/src/main/java/chat/stoat/screens/settings/MfaSettingsScreen.kt @@ -1,57 +1,241 @@ package chat.stoat.screens.settings +import android.app.Application +import android.net.Uri +import android.widget.Toast +import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.graphics.createBitmap +import androidx.core.graphics.set +import androidx.core.net.toUri import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewModelScope import androidx.navigation.NavController import chat.stoat.R import chat.stoat.api.routes.account.MfaSettings +import chat.stoat.api.routes.account.disableTotp +import chat.stoat.api.routes.account.enableTotp +import chat.stoat.api.routes.account.fetchAccount import chat.stoat.api.routes.account.fetchMfaSettings +import chat.stoat.api.routes.account.fetchRecoveryCodes +import chat.stoat.api.routes.account.generateTotpSecret +import chat.stoat.api.routes.account.regenerateRecoveryCodes +import chat.stoat.composables.mfa.MfaPromptSheet +import chat.stoat.composables.mfa.MfaPromptState +import chat.stoat.composables.mfa.RecoveryCodesDialog +import chat.stoat.composables.mfa.mfaErrorText +import chat.stoat.composables.vectorassets.MfaOff +import chat.stoat.composables.vectorassets.MfaOn +import chat.stoat.core.model.data.STOAT_WEB_APP +import chat.stoat.internals.Platform import chat.stoat.settings.dsl.SettingsPage +import chat.stoat.ui.theme.FragmentMono +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.qrcode.QRCodeWriter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.androidx.compose.koinViewModel + +@Stable +class TotpEnrollment(val secret: String, val otpauthUrl: String) { + val input = TextFieldState() + var busy by mutableStateOf(false) + var error by mutableStateOf(null) +} + +class MfaSettingsScreenViewModel(val context: Application) : ViewModel() { + val mfaPrompt = MfaPromptState() -class MfaSettingsScreenViewModel() : ViewModel() { var mfaStateLoaded by mutableStateOf(false) private set var mfaState by mutableStateOf(null) private set + var actionBusy by mutableStateOf(false) + private set + + var actionError by mutableStateOf(null) + private set + + var snackbarMessage by mutableStateOf(null) + + var displayedRecoveryCodes by mutableStateOf?>(null) + + var totpEnrollment by mutableStateOf(null) + private set + suspend fun loadDetails() { runCatching { fetchMfaSettings() } .onSuccess { mfaState = it } .also { mfaStateLoaded = true } } + + private fun withMfaTicket(block: suspend (mfaTicketToken: String) -> Unit) { + viewModelScope.launch { + if (actionBusy) return@launch + actionError = null + + val ticket = mfaPrompt.request() ?: return@launch + + actionBusy = true + runCatching { block(ticket.token) } + .onFailure { actionError = it.message } + actionBusy = false + } + } + + fun disableAuthenticator() = withMfaTicket { token -> + disableTotp(token) + loadDetails() + snackbarMessage = R.string.settings_mfa_totp_disabled_snackbar + } + + fun showRecoveryCodes() = withMfaTicket { token -> + displayedRecoveryCodes = fetchRecoveryCodes(token) + } + + fun rotateRecoveryCodes() = withMfaTicket { token -> + displayedRecoveryCodes = regenerateRecoveryCodes(token) + loadDetails() + } + + fun beginTotpEnrollment() = withMfaTicket { token -> + val secret = generateTotpSecret(token) + val email = runCatching { fetchAccount().email }.getOrNull() + totpEnrollment = TotpEnrollment(secret, otpauthUrl(secret, email)) + } + + fun confirmTotpEnrollment() { + val enrollment = totpEnrollment ?: return + if (enrollment.busy) return + + viewModelScope.launch { + enrollment.busy = true + enrollment.error = null + + runCatching { enableTotp(enrollment.input.text.toString()) } + .onFailure { + enrollment.error = it.message + enrollment.busy = false + } + .onSuccess { + loadDetails() + totpEnrollment = null + snackbarMessage = R.string.settings_mfa_totp_enabled_snackbar + } + } + } + + fun dismissTotpEnrollment() { + if (totpEnrollment?.busy != true) totpEnrollment = null + } + + private fun otpauthUrl(secret: String, email: String?): String { + val label = Uri.encode("Stoat:${email ?: "account"}") + return "otpauth://totp/$label?secret=$secret&issuer=Stoat" + } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun MfaSettingsScreen( navController: NavController, - viewModel: MfaSettingsScreenViewModel = viewModel() + viewModel: MfaSettingsScreenViewModel = koinViewModel() ) { + val context = LocalContext.current + val resources = LocalResources.current + LaunchedEffect(Unit) { viewModel.loadDetails() } + MfaPromptSheet(viewModel.mfaPrompt) + + viewModel.displayedRecoveryCodes?.let { codes -> + RecoveryCodesDialog(codes) { viewModel.displayedRecoveryCodes = null } + } + + viewModel.totpEnrollment?.let { enrollment -> + TotpEnrollmentSheet( + enrollment = enrollment, + onConfirm = { viewModel.confirmTotpEnrollment() }, + onDismiss = { viewModel.dismissTotpEnrollment() } + ) + } + SettingsPage( navController, title = { Text(stringResource(R.string.settings_mfa)) } ) { + LaunchedEffect(viewModel.snackbarMessage) { + viewModel.snackbarMessage?.let { + showSnackbar(resources.getString(it)) + viewModel.snackbarMessage = null + } + } + AnimatedContent( targetState = viewModel.mfaStateLoaded, ) { loaded -> @@ -67,8 +251,407 @@ fun MfaSettingsScreen( ) } } else { - Text("MFA Settings: ${viewModel.mfaState}") + Column { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surfaceContainerLow) + .padding(horizontal = 22.dp, vertical = 18.dp) + ) { + val mfaConsideredEnabled = + viewModel.mfaState?.totpMfa == true || viewModel.mfaState?.securityKeyMfa == true + + Image( + imageVector = if (mfaConsideredEnabled) MfaOn else MfaOff, + contentDescription = null, + modifier = Modifier + .height(128.dp) + .padding(bottom = 16.dp) + ) + Text( + text = stringResource(R.string.settings_mfa_status), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + if (mfaConsideredEnabled) { + Text( + text = stringResource(R.string.settings_mfa_status_enabled), + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center + ) + } else { + Text( + text = stringResource(R.string.settings_mfa_status_not_set_up), + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.settings_mfa_status_not_set_up_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 16.dp) + ) + } + } + + AnimatedContent(targetState = viewModel.actionError) { error -> + if (error != null) { + Text( + text = mfaErrorText(error), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp) + .padding(top = 12.dp) + ) + } + } + + if (viewModel.mfaState?.totpMfa == true) { + Subcategory( + title = { Text(stringResource(R.string.settings_mfa_disable_totp)) } + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 16.dp) + ) { + Text( + text = stringResource(R.string.settings_mfa_disable_totp_description), + style = MaterialTheme.typography.bodyMedium + ) + + Button( + onClick = { viewModel.disableAuthenticator() }, + enabled = !viewModel.actionBusy, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + ) { + Text(stringResource(R.string.settings_mfa_disable_totp_cta)) + } + } + } + } else { + Subcategory( + title = { Text(stringResource(R.string.settings_mfa_totp)) } + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 16.dp) + ) { + Text( + text = stringResource(R.string.settings_mfa_enable_totp_description), + style = MaterialTheme.typography.bodyMedium + ) + + Button( + onClick = { viewModel.beginTotpEnrollment() }, + enabled = !viewModel.actionBusy + ) { + Text(stringResource(R.string.settings_mfa_enable_totp_cta)) + } + } + } + } + + Subcategory( + title = { Text(stringResource(R.string.settings_mfa_recovery)) } + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 16.dp) + ) { + val recoveryActive = viewModel.mfaState?.recoveryActive == true + + Text( + text = stringResource( + if (recoveryActive) { + R.string.settings_mfa_recovery_description_active + } else { + R.string.settings_mfa_recovery_description_inactive + } + ), + style = MaterialTheme.typography.bodyMedium + ) + + if (recoveryActive) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = { viewModel.showRecoveryCodes() }, + enabled = !viewModel.actionBusy + ) { + Text(stringResource(R.string.settings_mfa_recovery_view_cta)) + } + + OutlinedButton( + onClick = { viewModel.rotateRecoveryCodes() }, + enabled = !viewModel.actionBusy + ) { + Text(stringResource(R.string.settings_mfa_recovery_regenerate_cta)) + } + } + } else { + Button( + onClick = { viewModel.rotateRecoveryCodes() }, + enabled = !viewModel.actionBusy + ) { + Text(stringResource(R.string.settings_mfa_recovery_generate_cta)) + } + } + } + } + + if (viewModel.mfaState?.securityKeyMfa == true) { + Subcategory( + title = { Text(stringResource(R.string.settings_mfa_security_keys)) } + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 16.dp) + ) { + Text( + text = stringResource(R.string.settings_mfa_security_keys_web_manage_description), + style = MaterialTheme.typography.bodyMedium + ) + + Button( + onClick = { + val customTab = CustomTabsIntent + .Builder() + .build() + customTab.launchUrl(context, "$STOAT_WEB_APP/app".toUri()) + } + ) { + Text(stringResource(R.string.settings_mfa_security_keys_web_manage_cta)) + } + } + } + } + } } } } -} \ No newline at end of file +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun TotpEnrollmentSheet( + enrollment: TotpEnrollment, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + val context = LocalContext.current + val resources = LocalResources.current + val clipboardManager = LocalClipboardManager.current + + ModalBottomSheet( + onDismissRequest = { if (!enrollment.busy) onDismiss() }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + sheetGesturesEnabled = false + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp) + .padding(bottom = 24.dp) + .imePadding() + ) { + Text( + text = stringResource(R.string.settings_mfa_totp_setup_title), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(4.dp)) + + Text( + text = stringResource(R.string.settings_mfa_totp_setup_scan), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(16.dp)) + + TotpQrCode(enrollment.otpauthUrl) + + Spacer(Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.settings_mfa_totp_setup_manual), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + SelectionContainer { + Text( + text = enrollment.secret, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FragmentMono + ) + ) + } + + IconButton(onClick = { + clipboardManager.setText(AnnotatedString(enrollment.secret)) + if (Platform.needsShowClipboardNotification()) { + Toast.makeText( + context, + resources.getString(R.string.copied), + Toast.LENGTH_SHORT + ).show() + } + }) { + Icon( + painter = painterResource(R.drawable.ic_content_copy_24dp), + contentDescription = stringResource(R.string.settings_mfa_totp_setup_copy_secret) + ) + } + } + + Spacer(Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.settings_mfa_totp_setup_code_lead), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) + + val transformation = remember { + InputTransformation { + val current = asCharSequence().toString() + val sanitized = current.filter(Char::isDigit).take(6) + if (sanitized != current) replace(0, length, sanitized) + } + } + + TextField( + state = enrollment.input, + label = { Text(stringResource(R.string.settings_mfa_totp_setup_code_label)) }, + inputTransformation = transformation, + lineLimits = TextFieldLineLimits.SingleLine, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done + ), + onKeyboardAction = { onConfirm() }, + textStyle = LocalTextStyle.current.copy( + fontFamily = FragmentMono, + letterSpacing = 2.sp + ), + enabled = !enrollment.busy, + modifier = Modifier.fillMaxWidth() + ) + + AnimatedContent(targetState = enrollment.error) { error -> + if (error != null) { + Text( + text = mfaErrorText(error), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 8.dp) + ) + } + } + + Spacer(Modifier.height(16.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + TextButton( + onClick = onDismiss, + enabled = !enrollment.busy + ) { + Text(stringResource(R.string.cancel)) + } + + Button( + onClick = onConfirm, + enabled = !enrollment.busy && enrollment.input.text.length == 6 + ) { + AnimatedContent(targetState = enrollment.busy) { busy -> + if (busy) { + LoadingIndicator(modifier = Modifier.size(24.dp)) + } else { + Text(stringResource(R.string.settings_mfa_totp_setup_verify)) + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun TotpQrCode(contents: String) { + var qrCode by remember(contents) { mutableStateOf(null) } + + LaunchedEffect(contents) { + qrCode = withContext(Dispatchers.Default) { + val matrix = QRCodeWriter().encode( + contents, + BarcodeFormat.QR_CODE, + 512, + 512, + mapOf(EncodeHintType.MARGIN to "1") + ) + + val bitmap = createBitmap(matrix.width, matrix.height) + + for (x in 0 until matrix.width) { + for (y in 0 until matrix.height) { + bitmap[x, y] = if (matrix.get(x, y)) { + android.graphics.Color.BLACK + } else { + android.graphics.Color.WHITE + } + } + } + + bitmap.asImageBitmap() + } + } + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(220.dp) + .clip(MaterialTheme.shapes.medium) + .background(Color.White) + .padding(12.dp) + ) { + qrCode?.let { bitmap -> + Image( + bitmap = bitmap, + contentDescription = null, + filterQuality = FilterQuality.None, + modifier = Modifier.fillMaxSize() + ) + } ?: LoadingIndicator(modifier = Modifier.size(48.dp)) + } +} diff --git a/app/src/main/res/drawable/ic_shield_lock_24dp.xml b/app/src/main/res/drawable/ic_shield_lock_24dp.xml new file mode 100644 index 00000000..da38090b --- /dev/null +++ b/app/src/main/res/drawable/ic_shield_lock_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9803d745..0dea5c26 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -84,6 +84,23 @@ Enter your password Enter your password to continue. + Verify it\'s you + For your security, confirm this action with one of your verification methods. + Confirm + Something went wrong + Your verification methods couldn\'t be loaded. + Retry + No methods available + This account doesn\'t have any verification methods that can authorise this action. + Close + Password + Authenticator app + Recovery code + Incorrect password. Please try again. + That code wasn\'t accepted. It may be incorrect or expired. Please try again. + This verification method isn\'t allowed for this action. + Something went wrong (%1$s). + About Stoat for Android Brought to you with ❤️ by the Stoat team. @@ -733,7 +750,38 @@ Manage Authenticator App Two-Factor Authentication + Two-factor authentication is + enabled + not set up + You don\'t have any 2FA methods set up. You should set up 2FA to add an extra layer of security to your account. Authenticator App + Disable Authenticator App + Disabling your Authenticator App will remove it as a 2FA method for your account. You will no longer be able to use it to log in. + Disable + Protect your account with 6-digit codes from apps like Google Authenticator, Aegis, or Authy. + Set up + Set up your authenticator app + Scan this QR code with your authenticator app. + Or enter this key manually: + Copy key + Then enter the 6-digit code the app shows: + 6-digit code + Verify and enable + Authenticator app enabled + Authenticator app disabled + Recovery Codes + Recovery codes let you regain access to your account if you lose your other 2FA methods. Keep them somewhere safe. + You don\'t have recovery codes yet. Generate them so you can regain access to your account if you lose your authenticator. + View codes + Regenerate + Generate codes + Your recovery codes + Each code can be used once. Store them somewhere safe — anyone with these codes can access your account. + Copy all + Done + Security Keys + You have security keys enabled on your account. You can view, add, and remove security keys your security keys from your account settings in a web browser. + Open browser Profile Profile picture