feat: mfa settings and reusable mfa prompt

This commit is contained in:
infi 2026-07-06 03:02:09 +02:00
parent 0cbdff5a0d
commit 97dff3a50e
15 changed files with 1850 additions and 10 deletions

3
.idea/.gitignore vendored
View File

@ -8,4 +8,5 @@
/copilot/chatSessions /copilot/chatSessions
# User-specific files # User-specific files
/deploymentTargetSelector.xml /deploymentTargetSelector.xml
/misc.xml /misc.xml
/androidTestResultsUserPreferences.xml

View File

@ -10,6 +10,7 @@ import chat.stoat.api.StoatAPI.initialize
import chat.stoat.api.internals.Members import chat.stoat.api.internals.Members
import chat.stoat.api.realtime.DisconnectionState import chat.stoat.api.realtime.DisconnectionState
import chat.stoat.api.realtime.RealtimeSocket 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.routes.user.fetchSelf
import chat.stoat.api.unreads.Unreads import chat.stoat.api.unreads.Unreads
import chat.stoat.core.model.data.STOAT_BASE import chat.stoat.core.model.data.STOAT_BASE
@ -106,7 +107,7 @@ val StoatHttp = HttpClient(OkHttp) {
val chuckerInterceptor = ChuckerInterceptor.Builder(StoatApplication.instance) val chuckerInterceptor = ChuckerInterceptor.Builder(StoatApplication.instance)
.collector(chuckerCollector) .collector(chuckerCollector)
.maxContentLength(250_000L) .maxContentLength(250_000L)
.redactHeaders(StoatAPI.TOKEN_HEADER_NAME) .redactHeaders(StoatAPI.TOKEN_HEADER_NAME, MFA_TICKET_HEADER_NAME)
.alwaysReadResponseBody(true) .alwaysReadResponseBody(true)
.createShortcut(false) .createShortcut(false)
.build() .build()

View File

@ -13,9 +13,17 @@ import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType import io.ktor.http.contentType
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException 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 @Serializable
data class LoginNegotiation( data class LoginNegotiation(
@ -51,17 +59,49 @@ data class LoginMfaAmendmentRecoveryCode(
val friendlyName: String 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 @Serializable
data class MfaResponseRecoveryCode( data class MfaResponseRecoveryCode(
@SerialName("recovery_code") @SerialName("recovery_code")
val recoveryCode: String val recoveryCode: String
) ) : MfaResponse
@Serializable @Serializable
data class MfaResponseTotpCode( data class MfaResponseTotpCode(
@SerialName("totp_code") @SerialName("totp_code")
val totpCode: String val totpCode: String
) ) : MfaResponse
object MfaResponseSerializer : KSerializer<MfaResponse> {
@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 @Serializable
data class MfaLoginSpec( data class MfaLoginSpec(

View File

@ -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<String> {
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<String> {
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<String> {
val res = StoatHttp.patch("/auth/mfa/recovery".api()) {
header(MFA_TICKET_HEADER_NAME, mfaTicketToken)
}
res.ensureSuccess("regenerate recovery codes")
return StoatJson.decodeFromString(res.bodyAsText())
}

View File

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

View File

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

View File

@ -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<MfaPromptSession?>(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<List<MfaMethod>?>(null)
private set
var loadError by mutableStateOf<String?>(null)
private set
var selected by mutableStateOf<MfaMethod?>(null)
private set
val input = TextFieldState()
var busy by mutableStateOf(false)
internal set
var error by mutableStateOf<String?>(null)
internal set
// how many various debounce techniques do we have in the codebase at this point?
private val events = Channel<MfaPromptEvent>(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
}
}
}

View File

@ -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<String>, 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))
}
}
)
}

View File

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

View File

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

View File

@ -9,6 +9,7 @@ import chat.stoat.screens.login.MfaScreenViewModel
import chat.stoat.screens.settings.AccountSettingsScreenViewModel import chat.stoat.screens.settings.AccountSettingsScreenViewModel
import chat.stoat.screens.settings.AppearanceSettingsScreenViewModel import chat.stoat.screens.settings.AppearanceSettingsScreenViewModel
import chat.stoat.screens.settings.DebugSettingsScreenViewModel import chat.stoat.screens.settings.DebugSettingsScreenViewModel
import chat.stoat.screens.settings.MfaSettingsScreenViewModel
import chat.stoat.screens.settings.NotificationsSettingsScreenViewModel import chat.stoat.screens.settings.NotificationsSettingsScreenViewModel
import chat.stoat.screens.settings.ProfileSettingsScreenViewModel import chat.stoat.screens.settings.ProfileSettingsScreenViewModel
import chat.stoat.screens.settings.SettingsScreenViewModel import chat.stoat.screens.settings.SettingsScreenViewModel
@ -34,4 +35,5 @@ val viewModelModule = module {
viewModel { AppearanceSettingsScreenViewModel(androidApplication()) } viewModel { AppearanceSettingsScreenViewModel(androidApplication()) }
viewModel { ChannelSettingsOverviewViewModel(androidApplication()) } viewModel { ChannelSettingsOverviewViewModel(androidApplication()) }
viewModel { AccountSettingsScreenViewModel(androidApplication()) } viewModel { AccountSettingsScreenViewModel(androidApplication()) }
viewModel { MfaSettingsScreenViewModel(androidApplication()) }
} }

View File

@ -435,7 +435,7 @@ fun AccountSettingsScreen(
} }
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
Switch( Switch(
checked = viewModel.mfaState?.totpMfa == true, checked = viewModel.mfaState?.totpMfa == true || viewModel.mfaState?.securityKeyMfa == true,
onCheckedChange = null onCheckedChange = null
) )
} }

View File

@ -1,57 +1,241 @@
package chat.stoat.screens.settings 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.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.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.fillMaxWidth
import androidx.compose.foundation.layout.height 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.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.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator 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.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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.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.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
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewModelScope
import androidx.navigation.NavController import androidx.navigation.NavController
import chat.stoat.R import chat.stoat.R
import chat.stoat.api.routes.account.MfaSettings 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.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.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<String?>(null)
}
class MfaSettingsScreenViewModel(val context: Application) : ViewModel() {
val mfaPrompt = MfaPromptState()
class MfaSettingsScreenViewModel() : ViewModel() {
var mfaStateLoaded by mutableStateOf(false) var mfaStateLoaded by mutableStateOf(false)
private set private set
var mfaState by mutableStateOf<MfaSettings?>(null) var mfaState by mutableStateOf<MfaSettings?>(null)
private set private set
var actionBusy by mutableStateOf(false)
private set
var actionError by mutableStateOf<String?>(null)
private set
var snackbarMessage by mutableStateOf<Int?>(null)
var displayedRecoveryCodes by mutableStateOf<List<String>?>(null)
var totpEnrollment by mutableStateOf<TotpEnrollment?>(null)
private set
suspend fun loadDetails() { suspend fun loadDetails() {
runCatching { fetchMfaSettings() } runCatching { fetchMfaSettings() }
.onSuccess { mfaState = it } .onSuccess { mfaState = it }
.also { mfaStateLoaded = true } .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) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun MfaSettingsScreen( fun MfaSettingsScreen(
navController: NavController, navController: NavController,
viewModel: MfaSettingsScreenViewModel = viewModel() viewModel: MfaSettingsScreenViewModel = koinViewModel()
) { ) {
val context = LocalContext.current
val resources = LocalResources.current
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.loadDetails() 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( SettingsPage(
navController, navController,
title = { Text(stringResource(R.string.settings_mfa)) } title = { Text(stringResource(R.string.settings_mfa)) }
) { ) {
LaunchedEffect(viewModel.snackbarMessage) {
viewModel.snackbarMessage?.let {
showSnackbar(resources.getString(it))
viewModel.snackbarMessage = null
}
}
AnimatedContent( AnimatedContent(
targetState = viewModel.mfaStateLoaded, targetState = viewModel.mfaStateLoaded,
) { loaded -> ) { loaded ->
@ -67,8 +251,407 @@ fun MfaSettingsScreen(
) )
} }
} else { } 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))
}
}
}
}
}
} }
} }
} }
} }
@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<ImageBitmap?>(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))
}
}

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,880Q341,845 250.5,720.5Q160,596 160,444L160,200L480,80L800,200L800,444Q800,596 709.5,720.5Q619,845 480,880ZM400,640L560,640Q577,640 588.5,628.5Q600,617 600,600L600,480Q600,463 588.5,451.5Q577,440 560,440L560,440L560,400Q560,367 536.5,343.5Q513,320 480,320Q447,320 423.5,343.5Q400,367 400,400L400,440L400,440Q383,440 371.5,451.5Q360,463 360,480L360,600Q360,617 371.5,628.5Q383,640 400,640ZM440,440L440,400Q440,383 451.5,371.5Q463,360 480,360Q497,360 508.5,371.5Q520,383 520,400L520,440L440,440Z"/>
</vector>

View File

@ -84,6 +84,23 @@
<string name="mfa_password_header">Enter your password</string> <string name="mfa_password_header">Enter your password</string>
<string name="mfa_password_lead">Enter your password to continue.</string> <string name="mfa_password_lead">Enter your password to continue.</string>
<string name="mfa_prompt_title">Verify it\'s you</string>
<string name="mfa_prompt_lead">For your security, confirm this action with one of your verification methods.</string>
<string name="mfa_prompt_confirm">Confirm</string>
<string name="mfa_prompt_load_error_title">Something went wrong</string>
<string name="mfa_prompt_load_error_description">Your verification methods couldn\'t be loaded.</string>
<string name="mfa_prompt_retry">Retry</string>
<string name="mfa_prompt_no_methods_title">No methods available</string>
<string name="mfa_prompt_no_methods_description">This account doesn\'t have any verification methods that can authorise this action.</string>
<string name="mfa_prompt_close">Close</string>
<string name="mfa_method_password">Password</string>
<string name="mfa_method_totp">Authenticator app</string>
<string name="mfa_method_recovery">Recovery code</string>
<string name="mfa_error_invalid_credentials">Incorrect password. Please try again.</string>
<string name="mfa_error_invalid_token">That code wasn\'t accepted. It may be incorrect or expired. Please try again.</string>
<string name="mfa_error_disallowed_method">This verification method isn\'t allowed for this action.</string>
<string name="mfa_error_generic">Something went wrong (%1$s).</string>
<string name="about">About</string> <string name="about">About</string>
<string name="about_full_name">Stoat for Android</string> <string name="about_full_name">Stoat for Android</string>
<string name="about_brought_to_you_by">Brought to you with ❤️ by the Stoat team.</string> <string name="about_brought_to_you_by">Brought to you with ❤️ by the Stoat team.</string>
@ -733,7 +750,38 @@
<string name="settings_account_mfa_totp_manage_alt">Manage Authenticator App</string> <string name="settings_account_mfa_totp_manage_alt">Manage Authenticator App</string>
<string name="settings_mfa">Two-Factor Authentication</string> <string name="settings_mfa">Two-Factor Authentication</string>
<string name="settings_mfa_status">Two-factor authentication is</string>
<string name="settings_mfa_status_enabled">enabled</string>
<string name="settings_mfa_status_not_set_up">not set up</string>
<string name="settings_mfa_status_not_set_up_description">You don\'t have any 2FA methods set up. You should set up 2FA to add an extra layer of security to your account.</string>
<string name="settings_mfa_totp">Authenticator App</string> <string name="settings_mfa_totp">Authenticator App</string>
<string name="settings_mfa_disable_totp">Disable Authenticator App</string>
<string name="settings_mfa_disable_totp_description">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.</string>
<string name="settings_mfa_disable_totp_cta">Disable</string>
<string name="settings_mfa_enable_totp_description">Protect your account with 6-digit codes from apps like Google Authenticator, Aegis, or Authy.</string>
<string name="settings_mfa_enable_totp_cta">Set up</string>
<string name="settings_mfa_totp_setup_title">Set up your authenticator app</string>
<string name="settings_mfa_totp_setup_scan">Scan this QR code with your authenticator app.</string>
<string name="settings_mfa_totp_setup_manual">Or enter this key manually:</string>
<string name="settings_mfa_totp_setup_copy_secret">Copy key</string>
<string name="settings_mfa_totp_setup_code_lead">Then enter the 6-digit code the app shows:</string>
<string name="settings_mfa_totp_setup_code_label">6-digit code</string>
<string name="settings_mfa_totp_setup_verify">Verify and enable</string>
<string name="settings_mfa_totp_enabled_snackbar">Authenticator app enabled</string>
<string name="settings_mfa_totp_disabled_snackbar">Authenticator app disabled</string>
<string name="settings_mfa_recovery">Recovery Codes</string>
<string name="settings_mfa_recovery_description_active">Recovery codes let you regain access to your account if you lose your other 2FA methods. Keep them somewhere safe.</string>
<string name="settings_mfa_recovery_description_inactive">You don\'t have recovery codes yet. Generate them so you can regain access to your account if you lose your authenticator.</string>
<string name="settings_mfa_recovery_view_cta">View codes</string>
<string name="settings_mfa_recovery_regenerate_cta">Regenerate</string>
<string name="settings_mfa_recovery_generate_cta">Generate codes</string>
<string name="settings_mfa_recovery_dialog_title">Your recovery codes</string>
<string name="settings_mfa_recovery_dialog_hint">Each code can be used once. Store them somewhere safe — anyone with these codes can access your account.</string>
<string name="settings_mfa_recovery_copy">Copy all</string>
<string name="settings_mfa_recovery_dialog_done">Done</string>
<string name="settings_mfa_security_keys">Security Keys</string>
<string name="settings_mfa_security_keys_web_manage_description">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.</string>
<string name="settings_mfa_security_keys_web_manage_cta">Open browser</string>
<string name="settings_profile">Profile</string> <string name="settings_profile">Profile</string>
<string name="settings_profile_profile_picture">Profile picture</string> <string name="settings_profile_profile_picture">Profile picture</string>