feat(auth): Add SSO confirmation screen.

This commit is contained in:
dkadrikj
2024-08-27 23:02:46 +02:00
committed by Dino Kadrikj
parent 9269d39aca
commit 6c2319e1ac
27 changed files with 1755 additions and 1 deletions
@@ -29,12 +29,14 @@ import me.proton.core.auth.data.usecase.IsCommonPasswordCheckEnabledImpl
import me.proton.core.auth.data.MissingScopeListenerImpl
import me.proton.core.auth.data.feature.IsFido2EnabledImpl
import me.proton.core.auth.data.repository.AuthRepositoryImpl
import me.proton.core.auth.data.repository.DeviceSecretRepositoryImpl
import me.proton.core.auth.data.usecase.IsCredentialLessEnabledImpl
import me.proton.core.auth.data.usecase.IsSsoCustomTabEnabledImpl
import me.proton.core.auth.data.usecase.IsSsoEnabledImpl
import me.proton.core.auth.domain.IsCommonPasswordCheckEnabled
import me.proton.core.auth.domain.feature.IsFido2Enabled
import me.proton.core.auth.domain.repository.AuthRepository
import me.proton.core.auth.domain.repository.DeviceSecretRepository
import me.proton.core.auth.domain.usecase.IsCredentialLessEnabled
import me.proton.core.auth.domain.usecase.IsSsoCustomTabEnabled
import me.proton.core.auth.domain.usecase.IsSsoEnabled
@@ -52,6 +54,9 @@ public interface CoreAuthModule {
@Singleton
public fun provideMissingScopeListener(impl: MissingScopeListenerImpl): MissingScopeListener
@Binds
public fun bindDeviceSecretRepository(impl: DeviceSecretRepositoryImpl): DeviceSecretRepository
public companion object {
@Provides
@Singleton
@@ -0,0 +1,124 @@
/**
* MIT License
*
* Copyright (c) 2018 Chris Campo
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package me.proton.core.auth.domain.usecase.sso
import kotlin.experimental.or
const val ALPHABET: String = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
val base32Lookup: IntArray = intArrayOf(
0xFF, 0xFF, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06,
0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E,
0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16,
0x17, 0x18, 0x19, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06,
0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E,
0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16,
0x17, 0x18, 0x19, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF
)
fun encode(bytes: ByteArray): String {
var i = 0
var index = 0
var digit: Int
var currByte: Int
var nextByte: Int
val base32 = StringBuffer((bytes.size + 7) * 8 / 5)
while (i < bytes.size) {
currByte = if (bytes[i] >= 0) bytes[i].toInt() else bytes[i] + 256
// Is the current digit going to span a byte boundary?
if (index > 3) {
nextByte = if (i + 1 < bytes.size) {
if (bytes[i + 1] >= 0) bytes[i + 1].toInt() else bytes[i + 1] + 256
} else {
0
}
digit = currByte and (0xFF shr index)
index = (index + 5) % 8
digit = digit shl index
digit = digit or (nextByte shr 8 - index)
i++
} else {
digit = currByte shr 8 - (index + 5) and 0x1F
index = (index + 5) % 8
if (index == 0)
i++
}
base32.append(ALPHABET[digit])
}
return base32.toString()
}
fun decode(base32: String): ByteArray {
var i = 0
var index = 0
var lookup: Int
var offset = 0
var digit: Int
val bytes = ByteArray(base32.length * 5 / 8)
while (i < base32.length) {
lookup = base32[i] - '0'
// Skip chars outside the lookup table
if (lookup < 0 || lookup >= base32Lookup.size) {
i++
continue
}
digit = base32Lookup[lookup]
// If this digit is not in the table, ignore it
if (digit == 0xFF) {
i++
continue
}
if (index <= 3) {
index = (index + 5) % 8
if (index == 0) {
bytes[offset] = bytes[offset] or digit.toByte()
offset++
if (offset >= bytes.size) break
} else {
bytes[offset] = bytes[offset] or (digit shl 8 - index).toByte()
}
} else {
index = (index + 5) % 8
bytes[offset] = bytes[offset] or digit.ushr(index).toByte()
offset++
if (offset >= bytes.size) break
bytes[offset] = bytes[offset] or (digit shl 8 - index).toByte()
}
i++
}
return bytes
}
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.auth.domain.usecase.sso
import me.proton.core.auth.domain.repository.DeviceSecretRepository
import me.proton.core.crypto.common.context.CryptoContext
import me.proton.core.domain.entity.UserId
import me.proton.core.util.kotlin.HashUtils
import javax.inject.Inject
class GenerateConfirmationCode @Inject constructor(
private val deviceSecretRepository: DeviceSecretRepository,
private val context: CryptoContext
) {
suspend operator fun invoke(
userId: UserId
): String {
val deviceSecret =
deviceSecretRepository.getByUserId(userId) ?: throw IllegalStateException("Device Secret not found.")
val decryptedDeviceSecret = context.keyStoreCrypto.decrypt(deviceSecret.secret)
val sha256DeviceSecret = HashUtils.sha256(decryptedDeviceSecret)
return encode(sha256DeviceSecret.toByteArray()).take(4)
}
}
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.auth.domain.usecase.sso
import me.proton.core.auth.domain.repository.DeviceSecretRepository
import me.proton.core.crypto.common.context.CryptoContext
import me.proton.core.domain.entity.UserId
import me.proton.core.util.kotlin.HashUtils
import javax.inject.Inject
class ValidateConfirmationCode @Inject constructor(
private val deviceSecretRepository: DeviceSecretRepository,
private val context: CryptoContext
) {
sealed interface Result {
object NoDeviceSecret : Result
object ConfirmationCodeInputError : Result
object ConfirmationCodeInvalid : Result
object ConfirmationCodeValid : Result
}
suspend operator fun invoke(
userId: UserId,
confirmationCode: String
): Result {
if (confirmationCode.isEmpty() or (confirmationCode.length != 4)) return Result.ConfirmationCodeInputError
val deviceSecret = deviceSecretRepository.getByUserId(userId) ?: return Result.NoDeviceSecret
val decryptedDeviceSecret = context.keyStoreCrypto.decrypt(deviceSecret.secret)
val sha256DeviceSecret = HashUtils.sha256(decryptedDeviceSecret)
return if (encode(sha256DeviceSecret.toByteArray()).take(4) == confirmationCode) Result.ConfirmationCodeValid
else Result.ConfirmationCodeInvalid
}
}
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.auth.presentation.compose.confirmationcode
public data class AvailableDeviceUIModel(
val id: String,
val authDeviceName: String,
val localizedClientName: String,
val lastActivityTime: Long,
val clientType: ClientType,
var lastActivityReadable: String? = null
)
public enum class ClientType {
Web, Android, iOS
}
@@ -0,0 +1,126 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.auth.presentation.compose.confirmationcode
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Icon
import androidx.compose.material.ListItem
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import me.proton.core.auth.presentation.compose.R
import me.proton.core.compose.component.DeferredCircularProgressIndicator
import me.proton.core.compose.theme.ProtonDimens
import me.proton.core.compose.theme.ProtonTypography
import me.proton.core.compose.theme.defaultNorm
import me.proton.core.compose.theme.defaultSmallWeak
internal const val CONFIRMATION_CODE_FIELD_TAG = "CONFIRMATION_CODE_FIELD_TAG"
@Composable
internal fun AvailableDevicesList(
modifier: Modifier = Modifier,
devices: List<AvailableDeviceUIModel>?
) {
LazyColumn(
modifier = modifier
.padding(
horizontal = ProtonDimens.DefaultSpacing,
vertical = ProtonDimens.DefaultSpacing
)
.fillMaxWidth()
) {
item {
Text(
modifier = Modifier
.padding(top = ProtonDimens.MediumSpacing),
text = stringResource(id = R.string.auth_login_devices_available),
style = ProtonTypography.Default.defaultSmallWeak
)
}
if (devices == null) {
item {
DeferredCircularProgressIndicator(
modifier = Modifier.fillMaxWidth()
)
}
} else if (devices.isEmpty()) {
item {
Text(
modifier = Modifier
.padding(top = ProtonDimens.MediumSpacing),
text = stringResource(id = R.string.auth_login_no_devices_available),
style = ProtonTypography.Default.defaultSmallWeak
)
}
} else {
items(devices, { device -> device.id }) { device ->
AvailableDeviceRow(device = device)
}
}
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
internal fun AvailableDeviceRow(
device: AvailableDeviceUIModel
) {
ListItem(
icon = {
val iconPainterResource = when (device.clientType) {
ClientType.Web -> painterResource(id = R.drawable.ic_proton_tv)
ClientType.Android,
ClientType.iOS -> painterResource(id = R.drawable.ic_proton_mobile)
}
Icon(
painter = iconPainterResource,
contentDescription = null
)
},
text = {
Text(
text = device.authDeviceName,
style = ProtonTypography.Default.defaultNorm
)
},
secondaryText = {
Column {
Text(
text = device.localizedClientName,
style = ProtonTypography.Default.defaultSmallWeak
)
Text(
text = stringResource(
id = R.string.auth_login_device_last_used,
device.lastActivityReadable ?: stringResource(R.string.auth_login_not_available)
),
style = ProtonTypography.Default.defaultSmallWeak
)
}
}
)
}
@@ -0,0 +1,137 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.auth.presentation.compose.confirmationcode
import android.content.res.Configuration
import androidx.annotation.StringRes
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.proton.core.auth.presentation.compose.R
import me.proton.core.auth.presentation.compose.SMALL_SCREEN_HEIGHT
import me.proton.core.compose.component.DeferredCircularProgressIndicator
import me.proton.core.compose.theme.ProtonDimens
import me.proton.core.compose.theme.ProtonTheme
@Composable
internal fun ConfirmationDigits(
modifier: Modifier = Modifier,
digits: List<Char>?,
@StringRes titleText: Int = R.string.auth_login_confirmation_code
) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(ProtonDimens.SmallSpacing))
.border(BorderStroke(1.dp, ProtonTheme.colors.separatorNorm))
.padding(ProtonDimens.DefaultSpacing)
) {
Column(
modifier = Modifier
.padding(start = ProtonDimens.DefaultSpacing)
) {
Text(
modifier = Modifier.align(Alignment.CenterHorizontally),
text = stringResource(id = titleText),
style = ProtonTheme.typography.body2Regular
)
if (digits == null) {
DeferredCircularProgressIndicator(
modifier = Modifier.fillMaxWidth()
)
} else {
Row(
modifier = Modifier
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
for (digit in digits) {
ConfirmationCodeDigit(digit = digit)
}
}
}
}
}
}
@Composable
private fun ConfirmationCodeDigit(
digit: Char
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.padding(ProtonDimens.ExtraSmallSpacing)
.size(height = 52.dp, width = 41.dp)
.clip(RoundedCornerShape(ProtonDimens.SmallSpacing))
.background(ProtonTheme.colors.backgroundSecondary)
) {
Text(
text = digit.toString(),
style = ProtonTheme.typography.hero
)
}
}
@Preview(name = "Light mode", showBackground = true)
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "Small screen height", heightDp = SMALL_SCREEN_HEIGHT)
@Preview(name = "Foldable", device = Devices.FOLDABLE)
@Preview(name = "Tablet", device = Devices.PIXEL_C)
@Preview(name = "Horizontal", widthDp = 800, heightDp = 360)
@Composable
internal fun ConfirmationDigitsPreview() {
ProtonTheme {
ConfirmationDigits(digits = listOf('6', '4', 'S', '3'))
}
}
@Preview(name = "Light mode", showBackground = true)
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "Small screen height", heightDp = SMALL_SCREEN_HEIGHT)
@Preview(name = "Foldable", device = Devices.FOLDABLE)
@Preview(name = "Tablet", device = Devices.PIXEL_C)
@Preview(name = "Horizontal", widthDp = 800, heightDp = 360)
@Composable
internal fun ConfirmationDigitsLoadingPreview() {
ProtonTheme {
ConfirmationDigits(digits = null)
}
}
@@ -0,0 +1,214 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.auth.presentation.compose.confirmationcode
import android.content.res.Configuration
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import me.proton.core.auth.presentation.compose.R
import me.proton.core.auth.presentation.compose.SMALL_SCREEN_HEIGHT
import me.proton.core.compose.component.ProtonSolidButton
import me.proton.core.compose.component.ProtonTextButton
import me.proton.core.compose.component.appbar.ProtonTopAppBar
import me.proton.core.compose.theme.LocalColors
import me.proton.core.compose.theme.ProtonDimens
import me.proton.core.compose.theme.ProtonTheme
import me.proton.core.compose.theme.ProtonTypography
import me.proton.core.compose.theme.defaultSmallWeak
@Composable
public fun ShareConfirmationCodeWithAdminScreen(
modifier: Modifier = Modifier,
onClose: () -> Unit = {},
onErrorMessage: (String?) -> Unit,
viewModel: ShareConfirmationCodeWithAdminViewModel = hiltViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle()
ShareConfirmationCodeWithAdminScreen(
modifier = modifier,
onClose = onClose,
onCloseClicked = { viewModel.submit(ShareConfirmationCodeAction.Close) },
onErrorMessage = onErrorMessage,
onCancelClicked = { viewModel.submit(it) },
onUseBackUpClicked = { viewModel.submit(it) },
state = state
)
}
@Composable
public fun ShareConfirmationCodeWithAdminScreen(
modifier: Modifier = Modifier,
onClose: () -> Unit = {},
onCloseClicked: () -> Unit = {},
onErrorMessage: (String?) -> Unit,
onCancelClicked: (ShareConfirmationCodeAction.Cancel) -> Unit = {},
onUseBackUpClicked: (ShareConfirmationCodeAction.UseBackUpPassword) -> Unit = {},
state: ShareConfirmationCodeWithAdminState
) {
when (state) {
is ShareConfirmationCodeWithAdminState.DataLoaded -> {
ShareConfirmationCodeWithAdminScreen(
modifier = modifier,
onCloseClicked = onCloseClicked,
onCancelClicked = onCancelClicked,
onUseBackUpClicked = onUseBackUpClicked,
username = state.username,
confirmationCode = state.confirmationCode.toCharArray().asList()
)
}
is ShareConfirmationCodeWithAdminState.Loading -> {
ShareConfirmationCodeWithAdminScreen(
modifier = modifier,
onCloseClicked = onCloseClicked,
onCancelClicked = onCancelClicked,
onUseBackUpClicked = onUseBackUpClicked
)
}
is ShareConfirmationCodeWithAdminState.Error -> LaunchedEffect(state) { onErrorMessage(state.message) }
is ShareConfirmationCodeWithAdminState.Cancel,
is ShareConfirmationCodeWithAdminState.Close -> LaunchedEffect(state) { onClose() }
}
}
@Composable
public fun ShareConfirmationCodeWithAdminScreen(
modifier: Modifier = Modifier,
onCloseClicked: () -> Unit = {},
onCancelClicked: (ShareConfirmationCodeAction.Cancel) -> Unit = {},
onUseBackUpClicked: (ShareConfirmationCodeAction.UseBackUpPassword) -> Unit = {},
username: String? = null,
confirmationCode: List<Char>? = null
) {
Scaffold(
modifier = modifier,
topBar = {
ProtonTopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = onCloseClicked) {
Icon(
painterResource(id = R.drawable.ic_proton_close),
contentDescription = stringResource(id = R.string.auth_login_close)
)
}
},
backgroundColor = LocalColors.current.backgroundNorm
)
}
) { paddingValues ->
Box(modifier = Modifier.padding(paddingValues)) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(id = R.string.auth_login_share_confirmation_code_with_admin),
style = ProtonTypography.Default.headline
)
if (username != null) {
Text(
modifier = Modifier
.padding(top = ProtonDimens.MediumSpacing),
text = stringResource(
id = R.string.auth_login_share_confirmation_code_with_admin_subtitle,
username
),
style = ProtonTypography.Default.defaultSmallWeak
)
}
Spacer(Modifier.size(ProtonDimens.DefaultSpacing))
ConfirmationDigits(digits = confirmationCode)
ProtonSolidButton(
contained = false,
onClick = { onUseBackUpClicked(ShareConfirmationCodeAction.UseBackUpPassword) },
modifier = Modifier
.padding(top = ProtonDimens.MediumSpacing)
.height(ProtonDimens.DefaultButtonMinHeight)
) {
Text(text = stringResource(R.string.auth_login_use_backup_password))
}
ProtonTextButton(
contained = false,
onClick = { onCancelClicked(ShareConfirmationCodeAction.Cancel) },
modifier = Modifier
.padding(vertical = ProtonDimens.MediumSpacing)
.height(ProtonDimens.DefaultButtonMinHeight),
) {
Text(text = stringResource(R.string.auth_login_cancel))
}
}
}
}
}
@Preview(name = "Light mode")
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "Small screen height", heightDp = SMALL_SCREEN_HEIGHT)
@Preview(name = "Foldable", device = Devices.FOLDABLE)
@Preview(name = "Tablet", device = Devices.PIXEL_C)
@Preview(name = "Horizontal", widthDp = 800, heightDp = 360)
@Composable
internal fun ShareConfirmationCodeWithAdminScreenPreview() {
ProtonTheme {
ShareConfirmationCodeWithAdminScreen(
confirmationCode = listOf('6', '4', 'S', '3'),
username = "test@protonmail.com"
)
}
}
@Preview(name = "Light mode", showBackground = true)
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "Small screen height", heightDp = SMALL_SCREEN_HEIGHT)
@Preview(name = "Foldable", device = Devices.FOLDABLE)
@Preview(name = "Tablet", device = Devices.PIXEL_C)
@Preview(name = "Horizontal", widthDp = 800, heightDp = 360)
@Composable
internal fun ShareConfirmationCodeWithAdminLoadingScreenPreview() {
ProtonTheme {
ShareConfirmationCodeWithAdminScreen(
confirmationCode = listOf('6', '4', 'S', '3'),
username = "test@protonmail.com"
)
}
}
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.auth.presentation.compose.confirmationcode
public sealed interface ShareConfirmationCodeWithAdminState {
public data object Loading : ShareConfirmationCodeWithAdminState
public data class DataLoaded(
val username: String,
val confirmationCode: String
) : ShareConfirmationCodeWithAdminState
public data object Close : ShareConfirmationCodeWithAdminState
public data object Cancel : ShareConfirmationCodeWithAdminState
public data class Error(val message: String?) : ShareConfirmationCodeWithAdminState
}
@@ -0,0 +1,108 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.auth.presentation.compose.confirmationcode
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.launch
import me.proton.core.auth.domain.usecase.sso.GenerateConfirmationCode
import me.proton.core.auth.presentation.compose.confirmationcode.ShareConfirmationCodeWithAdminScreen.getUserId
import me.proton.core.compose.viewmodel.stopTimeoutMillis
import me.proton.core.domain.entity.UserId
import me.proton.core.network.domain.ApiException
import me.proton.core.user.domain.usecase.GetUser
import javax.inject.Inject
@HiltViewModel
public class ShareConfirmationCodeWithAdminViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val getUser: GetUser,
private val generateConfirmationCode: GenerateConfirmationCode
) : ViewModel() {
private val userId by lazy { savedStateHandle.getUserId() }
private val actions = MutableSharedFlow<ShareConfirmationCodeAction>()
public val state: StateFlow<ShareConfirmationCodeWithAdminState> = load()
.flatMapLatest {
actions
.transform { action ->
val flow = when (action) {
ShareConfirmationCodeAction.Cancel,
ShareConfirmationCodeAction.Close -> onClose()
ShareConfirmationCodeAction.UseBackUpPassword -> useBackUpPassword()
}
emitAll(flow)
}.catch {
emit(ShareConfirmationCodeWithAdminState.Error(it.message))
}
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(stopTimeoutMillis),
ShareConfirmationCodeWithAdminState.Loading
)
public fun submit(action: ShareConfirmationCodeAction): Job = viewModelScope.launch {
actions.emit(action)
}
private fun load() = flow {
emit(ShareConfirmationCodeWithAdminState.Loading)
val user = getUser(userId, false)
emit(
ShareConfirmationCodeWithAdminState.DataLoaded(
username = user.email ?: user.name ?: "",
confirmationCode = generateConfirmationCode(userId)
)
)
}.catch {
when (it) {
is ApiException -> emit(ShareConfirmationCodeWithAdminState.Error(it.message))
else -> throw it
}
}
private fun useBackUpPassword() = flow<ShareConfirmationCodeWithAdminState> {
// TODO:
}
private fun onClose() = flow<ShareConfirmationCodeWithAdminState> {
emit(ShareConfirmationCodeWithAdminState.Close)
}
}
public object ShareConfirmationCodeWithAdminScreen {
public const val KEY_USERID: String = "UserId"
public fun SavedStateHandle.getUserId(): UserId = UserId(get<String>(KEY_USERID)!!)
}
@@ -0,0 +1,27 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.auth.presentation.compose.confirmationcode
public sealed interface ShareConfirmationOperation
public sealed interface ShareConfirmationCodeAction : ShareConfirmationOperation {
public data object Close : ShareConfirmationCodeAction
public data object Cancel : ShareConfirmationCodeAction
public data object UseBackUpPassword : ShareConfirmationCodeAction
}
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.auth.presentation.compose.confirmationcode
public sealed interface SignInRequestedForApprovalOperation
public sealed interface SignInRequestedForApprovalAction : SignInRequestedForApprovalOperation {
public data object Close : SignInRequestedForApprovalAction
public data class ValidateConfirmationCode(val confirmationCode: String) : SignInRequestedForApprovalAction
public data object Confirm : SignInRequestedForApprovalAction
public data object Reject : SignInRequestedForApprovalAction
}
@@ -0,0 +1,234 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.auth.presentation.compose.confirmationcode
import android.content.res.Configuration
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import me.proton.core.auth.presentation.compose.R
import me.proton.core.auth.presentation.compose.SMALL_SCREEN_HEIGHT
import me.proton.core.compose.component.ProtonOutlinedTextFieldWithError
import me.proton.core.compose.component.ProtonSolidButton
import me.proton.core.compose.component.ProtonTextButton
import me.proton.core.compose.component.appbar.ProtonTopAppBar
import me.proton.core.compose.theme.LocalColors
import me.proton.core.compose.theme.ProtonDimens
import me.proton.core.compose.theme.ProtonTheme
import me.proton.core.compose.theme.ProtonTypography
import me.proton.core.compose.theme.defaultSmallWeak
@Composable
public fun SignInRequestedForApprovalScreen(
modifier: Modifier = Modifier,
onClose: () -> Unit = {},
onErrorMessage: (String?) -> Unit = {},
viewModel: SignInRequestedForApprovalViewModel = hiltViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle()
SignInRequestedForApprovalScreen(
modifier = modifier,
onClose = onClose,
onCloseClicked = { viewModel.submit(SignInRequestedForApprovalAction.Close) },
onErrorMessage = onErrorMessage,
onConfirmClicked = { viewModel.submit(it) },
onRejectClicked = { viewModel.submit(it) },
onConfirmationCodeInputChange = { viewModel.submit(it) },
state = state
)
}
@Composable
public fun SignInRequestedForApprovalScreen(
modifier: Modifier = Modifier,
onClose: () -> Unit = {},
onErrorMessage: (String?) -> Unit = {},
onCloseClicked: () -> Unit = {},
onConfirmClicked: (SignInRequestedForApprovalAction.Confirm) -> Unit = {},
onRejectClicked: (SignInRequestedForApprovalAction.Reject) -> Unit = {},
onConfirmationCodeInputChange: (SignInRequestedForApprovalAction.ValidateConfirmationCode) -> Unit = {},
state: SignInRequestedForApprovalState
) {
when (state) {
is SignInRequestedForApprovalState.Idle -> {
SignInRequestedForApprovalScaffold(
modifier = modifier,
onCloseClicked = onCloseClicked,
onConfirmClicked = onConfirmClicked,
onConfirmationCodeInputChange = onConfirmationCodeInputChange,
onRejectClicked = onRejectClicked
)
}
is SignInRequestedForApprovalState.ConfirmationCodeResult -> {
SignInRequestedForApprovalScaffold(
modifier = modifier,
onCloseClicked = onCloseClicked,
onConfirmClicked = onConfirmClicked,
onRejectClicked = onRejectClicked,
onConfirmationCodeInputChange = onConfirmationCodeInputChange,
confirmationButtonClickable = state.success
)
}
is SignInRequestedForApprovalState.Error -> LaunchedEffect(state) { onErrorMessage(state.message) }
is SignInRequestedForApprovalState.Close -> LaunchedEffect(state) { onClose() }
}
}
@Composable
public fun SignInRequestedForApprovalScaffold(
modifier: Modifier = Modifier,
onCloseClicked: () -> Unit,
onConfirmClicked: (SignInRequestedForApprovalAction.Confirm) -> Unit,
onRejectClicked: (SignInRequestedForApprovalAction.Reject) -> Unit,
onConfirmationCodeInputChange: (SignInRequestedForApprovalAction.ValidateConfirmationCode) -> Unit,
confirmationButtonClickable: Boolean = false
) {
Scaffold(
modifier = modifier,
topBar = {
ProtonTopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = onCloseClicked) {
Icon(
painterResource(id = R.drawable.ic_proton_close),
contentDescription = stringResource(id = R.string.auth_login_close)
)
}
},
backgroundColor = LocalColors.current.backgroundNorm
)
}
) { paddingValues ->
Box(modifier = Modifier.padding(paddingValues)) {
ConfirmationCodeInputScreen(
onConfirmClicked = onConfirmClicked,
onRejectClicked = onRejectClicked,
onConfirmationCodeInputChange = onConfirmationCodeInputChange,
confirmationButtonClickable = confirmationButtonClickable
)
}
}
}
@Composable
private fun ConfirmationCodeInputScreen(
onConfirmClicked: (SignInRequestedForApprovalAction.Confirm) -> Unit,
onRejectClicked: (SignInRequestedForApprovalAction.Reject) -> Unit,
onConfirmationCodeInputChange: (SignInRequestedForApprovalAction.ValidateConfirmationCode) -> Unit,
confirmationButtonClickable: Boolean = false
) {
var confirmationCode by remember { mutableStateOf("") }
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(id = R.string.auth_login_signin_requested),
style = ProtonTypography.Default.headline
)
Text(
modifier = Modifier.padding(top = ProtonDimens.MediumSpacing),
text = stringResource(id = R.string.auth_login_signin_requested_subtitle),
style = ProtonTypography.Default.defaultSmallWeak
)
ProtonOutlinedTextFieldWithError(
text = confirmationCode,
onValueChanged = {
confirmationCode = it
onConfirmationCodeInputChange(SignInRequestedForApprovalAction.ValidateConfirmationCode(confirmationCode))
},
label = { Text(text = stringResource(id = R.string.auth_login_confirmation_code)) },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(top = ProtonDimens.DefaultSpacing)
.testTag(CONFIRMATION_CODE_FIELD_TAG)
)
Text(
modifier = Modifier
.padding(top = ProtonDimens.MediumSpacing),
text = stringResource(id = R.string.auth_login_signin_requested_note),
style = ProtonTypography.Default.defaultSmallWeak
)
ProtonSolidButton(
contained = false,
onClick = { onConfirmClicked(SignInRequestedForApprovalAction.Confirm) },
enabled = confirmationButtonClickable,
modifier = Modifier
.padding(top = ProtonDimens.MediumSpacing)
.height(ProtonDimens.DefaultButtonMinHeight)
) {
Text(text = stringResource(R.string.auth_login_yes_it_was_me))
}
ProtonTextButton(
contained = false,
onClick = { onRejectClicked(SignInRequestedForApprovalAction.Reject) },
modifier = Modifier
.padding(vertical = ProtonDimens.MediumSpacing)
.height(ProtonDimens.DefaultButtonMinHeight),
) {
Text(text = stringResource(R.string.auth_login_no_it_wasnt_me))
}
}
}
@Preview(name = "Light mode")
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "Small screen height", heightDp = SMALL_SCREEN_HEIGHT)
@Preview(name = "Foldable", device = Devices.FOLDABLE)
@Preview(name = "Tablet", device = Devices.PIXEL_C)
@Preview(name = "Horizontal", widthDp = 800, heightDp = 360)
@Composable
internal fun ConfirmationCodeSignInRequestedForApprovalScreenPreview() {
ProtonTheme {
SignInRequestedForApprovalScreen(
onErrorMessage = {},
state = SignInRequestedForApprovalState.Idle
)
}
}
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.auth.presentation.compose.confirmationcode
public sealed interface SignInRequestedForApprovalState {
public data object Idle : SignInRequestedForApprovalState
public data class ConfirmationCodeResult(
val success: Boolean
) : SignInRequestedForApprovalState
public data object Close : SignInRequestedForApprovalState
public data class Error(val message: String?) : SignInRequestedForApprovalState
}
@@ -0,0 +1,83 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.auth.presentation.compose.confirmationcode
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import me.proton.core.auth.domain.usecase.sso.ValidateConfirmationCode
import me.proton.core.auth.presentation.compose.confirmationcode.ShareConfirmationCodeWithAdminScreen.getUserId
import javax.inject.Inject
@HiltViewModel
public class SignInRequestedForApprovalViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val validateConfirmationCode: ValidateConfirmationCode
) : ViewModel() {
private val userId by lazy { savedStateHandle.getUserId() }
private val mutableState: MutableStateFlow<SignInRequestedForApprovalState> =
MutableStateFlow(SignInRequestedForApprovalState.Idle)
public val state: StateFlow<SignInRequestedForApprovalState> = mutableState.asStateFlow()
public fun submit(action: SignInRequestedForApprovalAction): Job = viewModelScope.launch {
when (action) {
is SignInRequestedForApprovalAction.Close -> close()
is SignInRequestedForApprovalAction.Confirm -> confirmRequest()
is SignInRequestedForApprovalAction.Reject -> rejectRequest()
is SignInRequestedForApprovalAction.ValidateConfirmationCode -> validateCode(action.confirmationCode)
}
}
private suspend fun validateCode(confirmationCode: String) {
val newState = when (validateConfirmationCode(userId, confirmationCode)) {
ValidateConfirmationCode.Result.ConfirmationCodeInputError -> SignInRequestedForApprovalState.Error("")
ValidateConfirmationCode.Result.ConfirmationCodeInvalid -> SignInRequestedForApprovalState.ConfirmationCodeResult(
success = false
)
ValidateConfirmationCode.Result.ConfirmationCodeValid -> SignInRequestedForApprovalState.ConfirmationCodeResult(
success = true
)
ValidateConfirmationCode.Result.NoDeviceSecret -> SignInRequestedForApprovalState.Error("")
}
mutableState.emit(newState)
}
private fun confirmRequest() {
}
private fun rejectRequest() {
}
private suspend fun close() {
mutableState.emit(SignInRequestedForApprovalState.Close)
}
}
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.auth.presentation.compose.confirmationcode
public sealed interface SignInSentForApprovalOperation
public sealed interface SignInSentForApprovalAction : SignInSentForApprovalOperation {
public data object Close : SignInSentForApprovalAction
public data object UseBackUpPassword : SignInSentForApprovalAction
public data object AskAdminForHelp : SignInSentForApprovalAction
}
@@ -0,0 +1,235 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.auth.presentation.compose.confirmationcode
import android.content.res.Configuration
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import me.proton.core.auth.presentation.compose.R
import me.proton.core.auth.presentation.compose.SMALL_SCREEN_HEIGHT
import me.proton.core.compose.component.ProtonSolidButton
import me.proton.core.compose.component.ProtonTextButton
import me.proton.core.compose.component.appbar.ProtonTopAppBar
import me.proton.core.compose.theme.LocalColors
import me.proton.core.compose.theme.ProtonDimens
import me.proton.core.compose.theme.ProtonTheme
import me.proton.core.compose.theme.ProtonTypography
import me.proton.core.compose.theme.defaultSmallWeak
@Composable
public fun SignInSentForApprovalScreen(
modifier: Modifier = Modifier,
onClose: () -> Unit = {},
onErrorMessage: (String?) -> Unit = {},
viewModel: SignInSentForApprovalViewModel = hiltViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle()
SignInSentForApprovalScreen(
modifier = modifier,
onClose = onClose,
onCloseClicked = { viewModel.submit(SignInSentForApprovalAction.Close) },
onErrorMessage = onErrorMessage,
onUseBackUpClicked = { viewModel.submit(it) },
onAskAdminClicked = { viewModel.submit(it) },
state = state
)
}
@Composable
public fun SignInSentForApprovalScreen(
modifier: Modifier = Modifier,
onClose: () -> Unit = {},
onCloseClicked: () -> Unit = {},
onErrorMessage: (String?) -> Unit = {},
onUseBackUpClicked: (SignInSentForApprovalAction.UseBackUpPassword) -> Unit,
onAskAdminClicked: (SignInSentForApprovalAction.AskAdminForHelp) -> Unit,
state: SignInSentForApprovalState
) {
when (state) {
is SignInSentForApprovalState.DataLoaded -> {
SignInSentForApprovalScreen(
modifier = modifier,
onCloseClicked = onCloseClicked,
onUseBackUpClicked = onUseBackUpClicked,
onAskAdminForHelpClicked = onAskAdminClicked,
confirmationCode = state.confirmationCode.toCharArray().asList(),
availableDevices = state.availableDevices
)
}
is SignInSentForApprovalState.Loading -> {
SignInSentForApprovalScreen(
modifier = modifier,
onCloseClicked = onCloseClicked,
onUseBackUpClicked = onUseBackUpClicked,
onAskAdminForHelpClicked = onAskAdminClicked
)
}
is SignInSentForApprovalState.Error -> LaunchedEffect(state) { onErrorMessage(state.message) }
is SignInSentForApprovalState.Close -> LaunchedEffect(state) { onClose() }
}
}
@Composable
public fun SignInSentForApprovalScreen(
modifier: Modifier = Modifier,
onCloseClicked: () -> Unit,
onUseBackUpClicked: (SignInSentForApprovalAction.UseBackUpPassword) -> Unit,
onAskAdminForHelpClicked: (SignInSentForApprovalAction.AskAdminForHelp) -> Unit,
confirmationCode: List<Char>? = null,
availableDevices: List<AvailableDeviceUIModel>? = null
) {
Scaffold(
modifier = modifier,
topBar = {
ProtonTopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = onCloseClicked) {
Icon(
painterResource(id = R.drawable.ic_proton_close),
contentDescription = stringResource(id = R.string.auth_login_close)
)
}
},
backgroundColor = LocalColors.current.backgroundNorm
)
}
) { paddingValues ->
Box(modifier = Modifier.padding(paddingValues)) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(id = R.string.auth_login_approve_signin_another_device),
style = ProtonTypography.Default.headline
)
Text(
modifier = Modifier.padding(top = ProtonDimens.MediumSpacing),
text = stringResource(id = R.string.auth_login_approve_signin_another_device_subtitle),
style = ProtonTypography.Default.defaultSmallWeak
)
Spacer(Modifier.size(ProtonDimens.DefaultSpacing))
ConfirmationDigits(digits = confirmationCode)
Spacer(Modifier.size(ProtonDimens.DefaultSpacing))
AvailableDevicesList(devices = availableDevices)
Spacer(Modifier.size(ProtonDimens.DefaultSpacing))
ProtonSolidButton(
contained = false,
onClick = { onUseBackUpClicked(SignInSentForApprovalAction.UseBackUpPassword) },
modifier = Modifier
.padding(top = ProtonDimens.MediumSpacing)
.height(ProtonDimens.DefaultButtonMinHeight)
) {
Text(text = stringResource(R.string.auth_login_use_backup_password))
}
ProtonTextButton(
contained = false,
onClick = { onAskAdminForHelpClicked(SignInSentForApprovalAction.AskAdminForHelp) },
modifier = Modifier
.padding(vertical = ProtonDimens.MediumSpacing)
.height(ProtonDimens.DefaultButtonMinHeight),
) {
Text(text = stringResource(R.string.auth_login_ask_admin_for_help))
}
}
}
}
}
@Preview(name = "Light mode")
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "Small screen height", heightDp = SMALL_SCREEN_HEIGHT)
@Preview(name = "Foldable", device = Devices.FOLDABLE)
@Preview(name = "Tablet", device = Devices.PIXEL_C)
@Preview(name = "Horizontal", widthDp = 800, heightDp = 360)
@Composable
internal fun ApproveSignInScreenPreview() {
ProtonTheme {
SignInSentForApprovalScreen(
confirmationCode = listOf('6', '4', 'S', '3'),
availableDevices = listOf(
AvailableDeviceUIModel(
id = "id1",
authDeviceName = "MacOS",
localizedClientName = "Proton Mail Chrome",
lastActivityTime = 1724945205966,
clientType = ClientType.Web
),
AvailableDeviceUIModel(
id = "id2",
authDeviceName = "Google Pixel 7a",
localizedClientName = "Proton Mail Android",
lastActivityTime = 1724945205966,
clientType = ClientType.Android
)
),
onUseBackUpClicked = {},
onAskAdminForHelpClicked = {},
onCloseClicked = {}
)
}
}
@Preview(name = "Light mode")
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "Small screen height", heightDp = SMALL_SCREEN_HEIGHT)
@Preview(name = "Foldable", device = Devices.FOLDABLE)
@Preview(name = "Tablet", device = Devices.PIXEL_C)
@Preview(name = "Horizontal", widthDp = 800, heightDp = 360)
@Composable
internal fun ApproveSignInScreenLoadingPreview() {
ProtonTheme {
SignInSentForApprovalScreen(
confirmationCode = null,
availableDevices = null,
onUseBackUpClicked = {},
onAskAdminForHelpClicked = {},
onCloseClicked = {}
)
}
}
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.auth.presentation.compose.confirmationcode
public sealed interface SignInSentForApprovalState {
public data object Loading : SignInSentForApprovalState
public data object Close : SignInSentForApprovalState
public data class DataLoaded(
val confirmationCode: String,
val availableDevices: List<AvailableDeviceUIModel>
) : SignInSentForApprovalState
public data class Error(val message: String?) : SignInSentForApprovalState
}
@@ -0,0 +1,51 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.auth.presentation.compose.confirmationcode
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
public class SignInSentForApprovalViewModel @Inject constructor(
// TODO: create use case for generating confirmation code & reading available devices from API
) : ViewModel() {
private val mutableState: MutableStateFlow<SignInSentForApprovalState> =
MutableStateFlow(SignInSentForApprovalState.Loading)
public val state: StateFlow<SignInSentForApprovalState> = mutableState.asStateFlow()
public fun submit(action: SignInSentForApprovalAction): Job = viewModelScope.launch {
when (action) {
SignInSentForApprovalAction.AskAdminForHelp -> TODO()
SignInSentForApprovalAction.Close -> onClose()
SignInSentForApprovalAction.UseBackUpPassword -> TODO()
}
}
private suspend fun onClose() {
mutableState.emit(SignInSentForApprovalState.Close)
}
}
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!--
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2024 Proton AG
~ This file is part of Proton AG and ProtonCore.
~
@@ -27,6 +28,24 @@
<string name="auth_login_username">Username or email</string>
<string name="auth_login_password">Enter your password</string>
<string name="auth_login_assistive_text">Please enter your Proton email or username.</string>
<string name="auth_login_confirmation_code">Confirmation Code</string>
<string name="auth_login_use_backup_password">Use backup password instead</string>
<string name="auth_login_ask_admin_for_help">Ask admin for help</string>
<string name="auth_login_approve_signin_another_device">Approve the sign-in from another device</string>
<string name="auth_login_approve_signin_another_device_subtitle">To make sure its really you trying to sign in to your account, review the confirmation code and approve the request from another device.</string>
<string name="auth_login_share_confirmation_code_with_admin">Share the confirmation code with your administrator</string>
<string name="auth_login_share_confirmation_code_with_admin_subtitle">To make sure its really you trying to sign-in, share the confirmation code with your administrator admin@privacybydefault.com so that they can approve the request for %s.</string>
<string name="auth_login_signin_requested">Sign-in requested on another device. Was it you?</string>
<string name="auth_login_signin_requested_subtitle">Check that the confirmation code match the code on your other device.</string>
<string name="auth_login_signin_requested_note">If you didn\'t make this request, cancel it now.</string>
<string name="auth_login_yes_it_was_me">Yes, it was me</string>
<string name="auth_login_no_it_wasnt_me">No, it wasn\'t me</string>
<string name="auth_login_devices_available">Devices available:</string>
<string name="auth_login_no_devices_available">No devices available:</string>
<string name="auth_login_device_last_used">Last used %s</string>
<string name="auth_login_cancel">Cancel</string>
<string name="auth_login_not_available">N/A</string>
<string name="backup_password_input_password_empty">Password cannot be empty</string>
<string name="backup_password_input_action_continue">Continue</string>
<string name="backup_password_input_action_help">Ask administrator for help</string>
@@ -0,0 +1,35 @@
package me.proton.core.auth.presentation.compose.confirmationcode
import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import app.cash.paparazzi.detectEnvironment
import org.junit.Rule
import org.junit.Test
class ShareConfirmationCodeWithAdminScreenTest {
@get:Rule
val paparazzi = Paparazzi(
deviceConfig = DeviceConfig.PIXEL_5,
theme = "ProtonTheme",
// Remove when layoutlib properly supports SDK 34 (https://github.com/cashapp/paparazzi/issues/1025).
environment = detectEnvironment().run {
copy(compileSdkVersion = 33, platformDir = platformDir.replace("34", "33"))
}
)
@Test
fun shareConfirmationCodeWithAdminScreenTest() {
paparazzi.snapshot {
ShareConfirmationCodeWithAdminScreen(
onCloseClicked = {},
onCancelClicked = {},
onUseBackUpClicked = {},
onErrorMessage = {},
state = ShareConfirmationCodeWithAdminState.DataLoaded(
confirmationCode = "64S3",
username = "test-username",
)
)
}
}
}
@@ -0,0 +1,31 @@
package me.proton.core.auth.presentation.compose.confirmationcode
import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import app.cash.paparazzi.detectEnvironment
import org.junit.Rule
import org.junit.Test
class SignInRequestedForApprovalScreenTest {
@get:Rule
val paparazzi = Paparazzi(
deviceConfig = DeviceConfig.PIXEL_5,
theme = "ProtonTheme",
// Remove when layoutlib properly supports SDK 34 (https://github.com/cashapp/paparazzi/issues/1025).
environment = detectEnvironment().run {
copy(compileSdkVersion = 33, platformDir = platformDir.replace("34", "33"))
}
)
@Test
fun signInRequestedForApprovalScreenTest() {
paparazzi.snapshot {
SignInRequestedForApprovalScreen(
onCloseClicked = {},
onConfirmClicked = {},
onRejectClicked = {},
state = SignInRequestedForApprovalState.Idle
)
}
}
}
@@ -0,0 +1,43 @@
package me.proton.core.auth.presentation.compose.confirmationcode
import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import app.cash.paparazzi.detectEnvironment
import org.junit.Rule
import org.junit.Test
class SignInSentForApprovalScreenTest {
@get:Rule
val paparazzi = Paparazzi(
deviceConfig = DeviceConfig.PIXEL_5,
theme = "ProtonTheme",
// Remove when layoutlib properly supports SDK 34 (https://github.com/cashapp/paparazzi/issues/1025).
environment = detectEnvironment().run {
copy(compileSdkVersion = 33, platformDir = platformDir.replace("34", "33"))
}
)
@Test
fun signInSentForApprovalScreenTest() {
paparazzi.snapshot {
SignInSentForApprovalScreen(
onCloseClicked = {},
onUseBackUpClicked = {},
onAskAdminClicked = {},
state = SignInSentForApprovalState.DataLoaded(
confirmationCode = "64S3", availableDevices =
listOf(
AvailableDeviceUIModel(
id = "device1",
authDeviceName = "Device Name",
localizedClientName = "Chrome",
lastActivityTime = 242344233124,
clientType = ClientType.Web
)
)
)
)
}
}
}
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:666b268866aed69d95f55085068f249d879e72ec593b53e1471a8b289e5002dd
size 45089
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4b43b08e4238ca8bbb32785c9c23dc5c58e9c57269ed5896906a756c99247061
size 32381
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5b8310b9293e4ac4a4b80d3f39765773e19c3aeda3828442865849f7457a4699
size 48320
@@ -27,6 +27,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import me.proton.android.core.coreexample.db.AppDatabase
import me.proton.core.account.data.db.AccountDatabase
import me.proton.core.auth.data.db.AuthDatabase
import me.proton.core.challenge.data.db.ChallengeDatabase
import me.proton.core.contact.data.local.db.ContactDatabase
import me.proton.core.eventmanager.data.db.EventMetadataDatabase
@@ -124,4 +125,7 @@ abstract class AppDatabaseBindsModule {
@Binds
abstract fun provideDeviceRecoveryDatabase(appDatabase: AppDatabase): DeviceRecoveryDatabase
@Binds
abstract fun provideAuthDatabase(appDatabase: AppDatabase): AuthDatabase
}