feat: mfa settings and reusable mfa prompt
This commit is contained in:
parent
0cbdff5a0d
commit
97dff3a50e
|
|
@ -8,4 +8,5 @@
|
||||||
/copilot/chatSessions
|
/copilot/chatSessions
|
||||||
# User-specific files
|
# User-specific files
|
||||||
/deploymentTargetSelector.xml
|
/deploymentTargetSelector.xml
|
||||||
/misc.xml
|
/misc.xml
|
||||||
|
/androidTestResultsUserPreferences.xml
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()) }
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue