feat: mfa settings and reusable mfa prompt
This commit is contained in:
parent
0cbdff5a0d
commit
97dff3a50e
|
|
@ -9,3 +9,4 @@
|
|||
# User-specific files
|
||||
/deploymentTargetSelector.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.realtime.DisconnectionState
|
||||
import chat.stoat.api.realtime.RealtimeSocket
|
||||
import chat.stoat.api.routes.account.MFA_TICKET_HEADER_NAME
|
||||
import chat.stoat.api.routes.user.fetchSelf
|
||||
import chat.stoat.api.unreads.Unreads
|
||||
import chat.stoat.core.model.data.STOAT_BASE
|
||||
|
|
@ -106,7 +107,7 @@ val StoatHttp = HttpClient(OkHttp) {
|
|||
val chuckerInterceptor = ChuckerInterceptor.Builder(StoatApplication.instance)
|
||||
.collector(chuckerCollector)
|
||||
.maxContentLength(250_000L)
|
||||
.redactHeaders(StoatAPI.TOKEN_HEADER_NAME)
|
||||
.redactHeaders(StoatAPI.TOKEN_HEADER_NAME, MFA_TICKET_HEADER_NAME)
|
||||
.alwaysReadResponseBody(true)
|
||||
.createShortcut(false)
|
||||
.build()
|
||||
|
|
|
|||
|
|
@ -13,9 +13,17 @@ import io.ktor.client.statement.bodyAsText
|
|||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.contentType
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.InternalSerializationApi
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.descriptors.PolymorphicKind
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.descriptors.buildSerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
|
||||
@Serializable
|
||||
data class LoginNegotiation(
|
||||
|
|
@ -51,17 +59,49 @@ data class LoginMfaAmendmentRecoveryCode(
|
|||
val friendlyName: String
|
||||
)
|
||||
|
||||
/**
|
||||
* To add a new MFA method, add a variant here plus its branch in [MfaResponseSerializer]
|
||||
* then describe the UI in [chat.stoat.composables.mfa.MfaMethod].
|
||||
*/
|
||||
@Serializable(with = MfaResponseSerializer::class)
|
||||
sealed interface MfaResponse
|
||||
|
||||
@Serializable
|
||||
data class MfaResponsePassword(
|
||||
val password: String
|
||||
) : MfaResponse
|
||||
|
||||
@Serializable
|
||||
data class MfaResponseRecoveryCode(
|
||||
@SerialName("recovery_code")
|
||||
val recoveryCode: String
|
||||
)
|
||||
) : MfaResponse
|
||||
|
||||
@Serializable
|
||||
data class MfaResponseTotpCode(
|
||||
@SerialName("totp_code")
|
||||
val totpCode: String
|
||||
)
|
||||
) : MfaResponse
|
||||
|
||||
object MfaResponseSerializer : KSerializer<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
|
||||
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.AppearanceSettingsScreenViewModel
|
||||
import chat.stoat.screens.settings.DebugSettingsScreenViewModel
|
||||
import chat.stoat.screens.settings.MfaSettingsScreenViewModel
|
||||
import chat.stoat.screens.settings.NotificationsSettingsScreenViewModel
|
||||
import chat.stoat.screens.settings.ProfileSettingsScreenViewModel
|
||||
import chat.stoat.screens.settings.SettingsScreenViewModel
|
||||
|
|
@ -34,4 +35,5 @@ val viewModelModule = module {
|
|||
viewModel { AppearanceSettingsScreenViewModel(androidApplication()) }
|
||||
viewModel { ChannelSettingsOverviewViewModel(androidApplication()) }
|
||||
viewModel { AccountSettingsScreenViewModel(androidApplication()) }
|
||||
viewModel { MfaSettingsScreenViewModel(androidApplication()) }
|
||||
}
|
||||
|
|
@ -435,7 +435,7 @@ fun AccountSettingsScreen(
|
|||
}
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Switch(
|
||||
checked = viewModel.mfaState?.totpMfa == true,
|
||||
checked = viewModel.mfaState?.totpMfa == true || viewModel.mfaState?.securityKeyMfa == true,
|
||||
onCheckedChange = null
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,57 +1,241 @@
|
|||
package chat.stoat.screens.settings
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.input.InputTransformation
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.set
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.NavController
|
||||
import chat.stoat.R
|
||||
import chat.stoat.api.routes.account.MfaSettings
|
||||
import chat.stoat.api.routes.account.disableTotp
|
||||
import chat.stoat.api.routes.account.enableTotp
|
||||
import chat.stoat.api.routes.account.fetchAccount
|
||||
import chat.stoat.api.routes.account.fetchMfaSettings
|
||||
import chat.stoat.api.routes.account.fetchRecoveryCodes
|
||||
import chat.stoat.api.routes.account.generateTotpSecret
|
||||
import chat.stoat.api.routes.account.regenerateRecoveryCodes
|
||||
import chat.stoat.composables.mfa.MfaPromptSheet
|
||||
import chat.stoat.composables.mfa.MfaPromptState
|
||||
import chat.stoat.composables.mfa.RecoveryCodesDialog
|
||||
import chat.stoat.composables.mfa.mfaErrorText
|
||||
import chat.stoat.composables.vectorassets.MfaOff
|
||||
import chat.stoat.composables.vectorassets.MfaOn
|
||||
import chat.stoat.core.model.data.STOAT_WEB_APP
|
||||
import chat.stoat.internals.Platform
|
||||
import chat.stoat.settings.dsl.SettingsPage
|
||||
import chat.stoat.ui.theme.FragmentMono
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
|
||||
@Stable
|
||||
class TotpEnrollment(val secret: String, val otpauthUrl: String) {
|
||||
val input = TextFieldState()
|
||||
var busy by mutableStateOf(false)
|
||||
var error by mutableStateOf<String?>(null)
|
||||
}
|
||||
|
||||
class MfaSettingsScreenViewModel(val context: Application) : ViewModel() {
|
||||
val mfaPrompt = MfaPromptState()
|
||||
|
||||
class MfaSettingsScreenViewModel() : ViewModel() {
|
||||
var mfaStateLoaded by mutableStateOf(false)
|
||||
private set
|
||||
var mfaState by mutableStateOf<MfaSettings?>(null)
|
||||
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() {
|
||||
runCatching { fetchMfaSettings() }
|
||||
.onSuccess { mfaState = it }
|
||||
.also { mfaStateLoaded = true }
|
||||
}
|
||||
|
||||
private fun withMfaTicket(block: suspend (mfaTicketToken: String) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
if (actionBusy) return@launch
|
||||
actionError = null
|
||||
|
||||
val ticket = mfaPrompt.request() ?: return@launch
|
||||
|
||||
actionBusy = true
|
||||
runCatching { block(ticket.token) }
|
||||
.onFailure { actionError = it.message }
|
||||
actionBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
fun disableAuthenticator() = withMfaTicket { token ->
|
||||
disableTotp(token)
|
||||
loadDetails()
|
||||
snackbarMessage = R.string.settings_mfa_totp_disabled_snackbar
|
||||
}
|
||||
|
||||
fun showRecoveryCodes() = withMfaTicket { token ->
|
||||
displayedRecoveryCodes = fetchRecoveryCodes(token)
|
||||
}
|
||||
|
||||
fun rotateRecoveryCodes() = withMfaTicket { token ->
|
||||
displayedRecoveryCodes = regenerateRecoveryCodes(token)
|
||||
loadDetails()
|
||||
}
|
||||
|
||||
fun beginTotpEnrollment() = withMfaTicket { token ->
|
||||
val secret = generateTotpSecret(token)
|
||||
val email = runCatching { fetchAccount().email }.getOrNull()
|
||||
totpEnrollment = TotpEnrollment(secret, otpauthUrl(secret, email))
|
||||
}
|
||||
|
||||
fun confirmTotpEnrollment() {
|
||||
val enrollment = totpEnrollment ?: return
|
||||
if (enrollment.busy) return
|
||||
|
||||
viewModelScope.launch {
|
||||
enrollment.busy = true
|
||||
enrollment.error = null
|
||||
|
||||
runCatching { enableTotp(enrollment.input.text.toString()) }
|
||||
.onFailure {
|
||||
enrollment.error = it.message
|
||||
enrollment.busy = false
|
||||
}
|
||||
.onSuccess {
|
||||
loadDetails()
|
||||
totpEnrollment = null
|
||||
snackbarMessage = R.string.settings_mfa_totp_enabled_snackbar
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissTotpEnrollment() {
|
||||
if (totpEnrollment?.busy != true) totpEnrollment = null
|
||||
}
|
||||
|
||||
private fun otpauthUrl(secret: String, email: String?): String {
|
||||
val label = Uri.encode("Stoat:${email ?: "account"}")
|
||||
return "otpauth://totp/$label?secret=$secret&issuer=Stoat"
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun MfaSettingsScreen(
|
||||
navController: NavController,
|
||||
viewModel: MfaSettingsScreenViewModel = viewModel()
|
||||
viewModel: MfaSettingsScreenViewModel = koinViewModel()
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val resources = LocalResources.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadDetails()
|
||||
}
|
||||
|
||||
MfaPromptSheet(viewModel.mfaPrompt)
|
||||
|
||||
viewModel.displayedRecoveryCodes?.let { codes ->
|
||||
RecoveryCodesDialog(codes) { viewModel.displayedRecoveryCodes = null }
|
||||
}
|
||||
|
||||
viewModel.totpEnrollment?.let { enrollment ->
|
||||
TotpEnrollmentSheet(
|
||||
enrollment = enrollment,
|
||||
onConfirm = { viewModel.confirmTotpEnrollment() },
|
||||
onDismiss = { viewModel.dismissTotpEnrollment() }
|
||||
)
|
||||
}
|
||||
|
||||
SettingsPage(
|
||||
navController,
|
||||
title = { Text(stringResource(R.string.settings_mfa)) }
|
||||
) {
|
||||
LaunchedEffect(viewModel.snackbarMessage) {
|
||||
viewModel.snackbarMessage?.let {
|
||||
showSnackbar(resources.getString(it))
|
||||
viewModel.snackbarMessage = null
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedContent(
|
||||
targetState = viewModel.mfaStateLoaded,
|
||||
) { loaded ->
|
||||
|
|
@ -67,8 +251,407 @@ fun MfaSettingsScreen(
|
|||
)
|
||||
}
|
||||
} else {
|
||||
Text("MFA Settings: ${viewModel.mfaState}")
|
||||
Column {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
||||
.padding(horizontal = 22.dp, vertical = 18.dp)
|
||||
) {
|
||||
val mfaConsideredEnabled =
|
||||
viewModel.mfaState?.totpMfa == true || viewModel.mfaState?.securityKeyMfa == true
|
||||
|
||||
Image(
|
||||
imageVector = if (mfaConsideredEnabled) MfaOn else MfaOff,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.height(128.dp)
|
||||
.padding(bottom = 16.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.settings_mfa_status),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
if (mfaConsideredEnabled) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_mfa_status_enabled),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_mfa_status_not_set_up),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.settings_mfa_status_not_set_up_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedContent(targetState = viewModel.actionError) { error ->
|
||||
if (error != null) {
|
||||
Text(
|
||||
text = mfaErrorText(error),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp)
|
||||
.padding(top = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (viewModel.mfaState?.totpMfa == true) {
|
||||
Subcategory(
|
||||
title = { Text(stringResource(R.string.settings_mfa_disable_totp)) }
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_mfa_disable_totp_description),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.disableAuthenticator() },
|
||||
enabled = !viewModel.actionBusy,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
) {
|
||||
Text(stringResource(R.string.settings_mfa_disable_totp_cta))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Subcategory(
|
||||
title = { Text(stringResource(R.string.settings_mfa_totp)) }
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_mfa_enable_totp_description),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.beginTotpEnrollment() },
|
||||
enabled = !viewModel.actionBusy
|
||||
) {
|
||||
Text(stringResource(R.string.settings_mfa_enable_totp_cta))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Subcategory(
|
||||
title = { Text(stringResource(R.string.settings_mfa_recovery)) }
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
) {
|
||||
val recoveryActive = viewModel.mfaState?.recoveryActive == true
|
||||
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (recoveryActive) {
|
||||
R.string.settings_mfa_recovery_description_active
|
||||
} else {
|
||||
R.string.settings_mfa_recovery_description_inactive
|
||||
}
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
if (recoveryActive) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = { viewModel.showRecoveryCodes() },
|
||||
enabled = !viewModel.actionBusy
|
||||
) {
|
||||
Text(stringResource(R.string.settings_mfa_recovery_view_cta))
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.rotateRecoveryCodes() },
|
||||
enabled = !viewModel.actionBusy
|
||||
) {
|
||||
Text(stringResource(R.string.settings_mfa_recovery_regenerate_cta))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = { viewModel.rotateRecoveryCodes() },
|
||||
enabled = !viewModel.actionBusy
|
||||
) {
|
||||
Text(stringResource(R.string.settings_mfa_recovery_generate_cta))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (viewModel.mfaState?.securityKeyMfa == true) {
|
||||
Subcategory(
|
||||
title = { Text(stringResource(R.string.settings_mfa_security_keys)) }
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_mfa_security_keys_web_manage_description),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val customTab = CustomTabsIntent
|
||||
.Builder()
|
||||
.build()
|
||||
customTab.launchUrl(context, "$STOAT_WEB_APP/app".toUri())
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.settings_mfa_security_keys_web_manage_cta))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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_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_full_name">Stoat for Android</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_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_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_picture">Profile picture</string>
|
||||
|
|
|
|||
Loading…
Reference in New Issue