Skip to content

Commit

Permalink
TIQR-473: Verify identity screen
Browse files Browse the repository at this point in the history
  • Loading branch information
dzolnai committed Oct 25, 2024
1 parent b0bdfcc commit cbf1303
Show file tree
Hide file tree
Showing 20 changed files with 729 additions and 181 deletions.
7 changes: 7 additions & 0 deletions app/src/main/kotlin/nl/eduid/di/api/EduIdApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import nl.eduid.di.model.DeleteServiceRequest
import nl.eduid.di.model.DeleteTokensRequest
import nl.eduid.di.model.EmailChangeRequest
import nl.eduid.di.model.EnrollResponse
import nl.eduid.di.model.IdpScoping
import nl.eduid.di.model.InstitutionNameResponse
import nl.eduid.di.model.LinkedAccount
import nl.eduid.di.model.RequestEduIdAccount
Expand Down Expand Up @@ -120,4 +121,10 @@ interface EduIdApi {
suspend fun checkHashIsValid(
@Query("hash") hash: String,
): Response<Boolean>

@GET("/mobile/api/sp/verify/link")
suspend fun getStartExternalAccountLink(
@Query("idpScoping") idpScoping: IdpScoping,
@Query("bankId") bankId: String?
): Response<UrlResponse>
}
300 changes: 156 additions & 144 deletions app/src/main/kotlin/nl/eduid/di/assist/DataAssistant.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import nl.eduid.di.model.IdpScoping
import nl.eduid.di.model.LinkedAccount
import nl.eduid.di.model.SelfAssertedName
import nl.eduid.di.model.TokenResponse
Expand All @@ -17,158 +18,169 @@ import timber.log.Timber
import javax.inject.Inject

class DataAssistant
@Inject
constructor(
private val infoRepository: PersonalInfoRepository,
private val storageRepository: StorageRepository,
@DefaultDispatcher private val dispatcher: CoroutineDispatcher,
) {
private val cachedDetails = MutableStateFlow<SaveableResult<UserDetails>?>(null)
val observableDetails: Flow<SaveableResult<UserDetails>?> = cachedDetails.map { knownValue ->
if (knownValue == null) {
val fromNetwork = loadInCache()
fromNetwork.fold(
onSuccess = {
SaveableResult.Success(it)
},
onFailure = {
if (it is UnauthorizedException) {
storageRepository.clearInvalidAuth()
SaveableResult.LoadError(it)
} else {
SaveableResult.LoadError(
DataFetchException(
"Failed to get user notification settings",
it,
),
)
}
},
)
} else {
knownValue
}
}

private suspend fun loadInCache(): Result<UserDetails> = withContext(dispatcher) {
infoRepository.getUserDetailsResult()
}

suspend fun changeEmail(newEmail: String): Int? = try {
infoRepository.changeEmail(newEmail)
} catch (e: UnauthorizedException) {
storageRepository.clearInvalidAuth()
throw e
}

suspend fun getTokensForUser(): List<TokenResponse>? = try {
infoRepository.getTokensForUser()
} catch (e: UnauthorizedException) {
storageRepository.clearInvalidAuth()
throw e
}

suspend fun getErringUserDetails(): UserDetails? = try {
infoRepository.getErringUserDetails()
} catch (e: UnauthorizedException) {
storageRepository.clearInvalidAuth()
throw e
}

suspend fun confirmEmail(confirmEmailHash: String): UserDetails? = try {
infoRepository.confirmEmailUpdate(confirmEmailHash)
} catch (e: UnauthorizedException) {
storageRepository.clearInvalidAuth()
throw e
}

suspend fun removeService(serviceId: String): UserDetails? = try {
infoRepository.removeService(serviceId)
} catch (e: UnauthorizedException) {
storageRepository.clearInvalidAuth()
throw e
}

suspend fun updateTokens(revokeToken: TokenResponse): UserDetails? = try {
infoRepository.revokeToken(revokeToken)
} catch (e: UnauthorizedException) {
storageRepository.clearInvalidAuth()
throw e
}

suspend fun getInstitutionName(schacHome: String): String? = try {
infoRepository.getInstitutionName(schacHome)
} catch (e: UnauthorizedException) {
storageRepository.clearInvalidAuth()
throw e
}

suspend fun removeConnection(institutionId: String) = withContext(dispatcher) {
val currentDetails = currentCachedSettings() ?: fetchDetails()
val linkedAccount =
currentDetails?.linkedAccounts?.firstOrNull { it.institutionIdentifier == institutionId }
linkedAccount?.let {
val updatedDetails = infoRepository.removeConnectionResult(it)
forwardWithFallback(updatedDetails, currentDetails, "Remove connection")
}
}

suspend fun removeConnection(linkedAccount: LinkedAccount?) = withContext(dispatcher) {
linkedAccount?.let {
val knownDetails = currentCachedSettings() ?: fetchDetails()
val updatedDetails = infoRepository.removeConnectionResult(it)
if (knownDetails != null) {
forwardWithFallback(updatedDetails, knownDetails, "Remove connection")
@Inject
constructor(
private val infoRepository: PersonalInfoRepository,
private val storageRepository: StorageRepository,
@DefaultDispatcher private val dispatcher: CoroutineDispatcher,
) {
private val cachedDetails = MutableStateFlow<SaveableResult<UserDetails>?>(null)
val observableDetails: Flow<SaveableResult<UserDetails>?> = cachedDetails.map { knownValue ->
if (knownValue == null) {
suspend fun handleError(ex: Throwable): SaveableResult.LoadError {
return if (ex is UnauthorizedException) {
storageRepository.clearInvalidAuth()
SaveableResult.LoadError(ex)
} else {
SaveableResult.LoadError(DataFetchException("Failed to get user notification settings", ex))
}
}
}

suspend fun getStartLinkAccount(): String? = withContext(dispatcher) {
val result = infoRepository.getStartLinkAccountResult()
result.fold(
val fromNetwork = try { loadInCache() } catch (ex: Exception) { return@map handleError(ex) }
fromNetwork.fold(
onSuccess = {
it?.url
SaveableResult.Success(it)
},
onFailure = {
storageRepository.clearInvalidAuth()
null
handleError(it)
},
)
} else {
knownValue
}

suspend fun updateName(selfAssertedName: SelfAssertedName): UserDetails? = try {
infoRepository.updateName(selfAssertedName)
} catch (e: UnauthorizedException) {
storageRepository.clearInvalidAuth()
throw e
}

private suspend fun loadInCache(): Result<UserDetails> = withContext(dispatcher) {
infoRepository.getUserDetailsResult()
}

suspend fun changeEmail(newEmail: String): Int? = try {
infoRepository.changeEmail(newEmail)
} catch (e: UnauthorizedException) {
storageRepository.clearInvalidAuth()
throw e
}

suspend fun getTokensForUser(): List<TokenResponse>? = try {
infoRepository.getTokensForUser()
} catch (e: UnauthorizedException) {
storageRepository.clearInvalidAuth()
throw e
}

suspend fun getErringUserDetails(): UserDetails? = try {
infoRepository.getErringUserDetails()
} catch (e: UnauthorizedException) {
storageRepository.clearInvalidAuth()
throw e
}

suspend fun confirmEmail(confirmEmailHash: String): UserDetails? = try {
infoRepository.confirmEmailUpdate(confirmEmailHash)
} catch (e: UnauthorizedException) {
storageRepository.clearInvalidAuth()
throw e
}

suspend fun removeService(serviceId: String): UserDetails? = try {
infoRepository.removeService(serviceId)
} catch (e: UnauthorizedException) {
storageRepository.clearInvalidAuth()
throw e
}

suspend fun updateTokens(revokeToken: TokenResponse): UserDetails? = try {
infoRepository.revokeToken(revokeToken)
} catch (e: UnauthorizedException) {
storageRepository.clearInvalidAuth()
throw e
}

suspend fun getInstitutionName(schacHome: String): String? = try {
infoRepository.getInstitutionName(schacHome)
} catch (e: UnauthorizedException) {
storageRepository.clearInvalidAuth()
throw e
}

suspend fun removeConnection(institutionId: String) = withContext(dispatcher) {
val currentDetails = currentCachedSettings() ?: fetchDetails()
val linkedAccount =
currentDetails?.linkedAccounts?.firstOrNull { it.institutionIdentifier == institutionId }
linkedAccount?.let {
val updatedDetails = infoRepository.removeConnectionResult(it)
forwardWithFallback(updatedDetails, currentDetails, "Remove connection")
}

private suspend fun forwardWithFallback(result: Result<UserDetails>, knownDetails: UserDetails, operation: String = "") =
result.fold(
onSuccess = {
Timber.e("Forwarding new details")
cachedDetails.emit(SaveableResult.Success(it))
},
onFailure = {
Timber.e(it, "Failed to perform $operation")
cachedDetails.emit(
SaveableResult.Success(knownDetails, OperationFailException(operation, null, it)),
)
},
)

private suspend fun currentCachedSettings(): UserDetails? = when (val current = cachedDetails.firstOrNull()) {
is SaveableResult.LoadError -> null
is SaveableResult.Success -> current.data
null -> null
}

suspend fun removeConnection(linkedAccount: LinkedAccount?) = withContext(dispatcher) {
linkedAccount?.let {
val knownDetails = currentCachedSettings() ?: fetchDetails()
val updatedDetails = infoRepository.removeConnectionResult(it)
if (knownDetails != null) {
forwardWithFallback(updatedDetails, knownDetails, "Remove connection")
}
}

private suspend fun fetchDetails(): UserDetails? = withContext(dispatcher) {
val result = infoRepository.getUserDetailsResult()
result.fold(onSuccess = {
it
}, onFailure = {
}

suspend fun getStartLinkAccount(): String? = withContext(dispatcher) {
val result = infoRepository.getStartLinkAccountResult()
result.fold(
onSuccess = {
it?.url
},
onFailure = {
storageRepository.clearInvalidAuth()
null
})
}
}
},
)
}

suspend fun getExternalAccountLinkResult(idpScoping: IdpScoping, bankId: String?): String? = withContext(dispatcher) {
val result = infoRepository.getExternalAccountLinkResult(idpScoping, bankId)
result.fold(
onSuccess = {
it?.url
},
onFailure = {
storageRepository.clearInvalidAuth()
null
},
)
}

suspend fun updateName(selfAssertedName: SelfAssertedName): UserDetails? = try {
infoRepository.updateName(selfAssertedName)
} catch (e: UnauthorizedException) {
storageRepository.clearInvalidAuth()
throw e
}

private suspend fun forwardWithFallback(result: Result<UserDetails>, knownDetails: UserDetails, operation: String = "") =
result.fold(
onSuccess = {
Timber.e("Forwarding new details")
cachedDetails.emit(SaveableResult.Success(it))
},
onFailure = {
Timber.e(it, "Failed to perform $operation")
cachedDetails.emit(
SaveableResult.Success(knownDetails, OperationFailException(operation, null, it)),
)
},
)

private suspend fun currentCachedSettings(): UserDetails? = when (val current = cachedDetails.firstOrNull()) {
is SaveableResult.LoadError -> null
is SaveableResult.Success -> current.data
null -> null
}

private suspend fun fetchDetails(): UserDetails? = withContext(dispatcher) {
val result = infoRepository.getUserDetailsResult()
result.fold(onSuccess = {
it
}, onFailure = {
null
})
}
}
11 changes: 10 additions & 1 deletion app/src/main/kotlin/nl/eduid/di/model/EduIdModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -197,4 +197,13 @@ data class ConfirmedName(
data class UpdatePasswordRequest(
val newPassword: String,
val hash: String,
) : Parcelable
) : Parcelable

enum class IdpScoping {
@Json(name = "eherkenning")
EHERKENNING,
@Json(name = "idin")
IDIN,
@Json(name = "studielink")
STUDIELINK
}
10 changes: 6 additions & 4 deletions app/src/main/kotlin/nl/eduid/graphs/MainGraph.kt
Original file line number Diff line number Diff line change
Expand Up @@ -507,11 +507,13 @@ fun MainGraph(
//region Verify identity
composable(VerifyIdentityRoute.route) {
val viewModel = hiltViewModel<VerifyIdentityViewModel>(it)
VerifyIdentityScreen(viewModel = viewModel) {
navController.popBackStack()
}
VerifyIdentityScreen(
viewModel = viewModel,
goToBankSelectionScreen = { /* TODO */ },
goBack = { navController.popBackStack() }
)
}
//endregion
//endregion
}

fun NavController.goToEmailSent(email: String, reason: String = LOGIN_REASON) = navigate(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ fun HomePageNoAccountContent(
}
if (showBottomSheet) {
ModalBottomSheet(
windowInsets = WindowInsets(bottom = 0),
contentWindowInsets = { WindowInsets(bottom = 0) },
onDismissRequest = {
showBottomSheet = false
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ fun HomePageWithAccountContent(
}
if (showBottomSheet) {
ModalBottomSheet(
windowInsets = WindowInsets(bottom = 0),
contentWindowInsets = { WindowInsets(bottom = 0) },
onDismissRequest = {
showBottomSheet = false
},
Expand Down
Loading

0 comments on commit cbf1303

Please sign in to comment.