mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
Crypto identity reset (#3107)
* Add EncryptionReset password entry screen * Expose client proxy identity reset method * Update the IdentityConfirmationScreen to the latest designs * Replace the old recovery key reset screen with a new EncryptionReset one * Fixes #3102 - Add support for resetting the user's whole crypto identity * Address review comments * Bump the SDK to v1.0.30
This commit is contained in:
+90
@@ -0,0 +1,90 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
enum EncryptionResetScreenCoordinatorAction {
|
||||
case cancel
|
||||
case requestOIDCAuthorisation(URL)
|
||||
case resetFinished
|
||||
}
|
||||
|
||||
struct EncryptionResetScreenCoordinatorParameters {
|
||||
let clientProxy: ClientProxyProtocol
|
||||
let navigationStackCoordinator: NavigationStackCoordinator
|
||||
let userIndicatorController: UserIndicatorControllerProtocol
|
||||
}
|
||||
|
||||
final class EncryptionResetScreenCoordinator: CoordinatorProtocol {
|
||||
private let parameters: EncryptionResetScreenCoordinatorParameters
|
||||
private let viewModel: EncryptionResetScreenViewModelProtocol
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private let actionsSubject: PassthroughSubject<EncryptionResetScreenCoordinatorAction, Never> = .init()
|
||||
var actionsPublisher: AnyPublisher<EncryptionResetScreenCoordinatorAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(parameters: EncryptionResetScreenCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
viewModel = EncryptionResetScreenViewModel(clientProxy: parameters.clientProxy,
|
||||
userIndicatorController: parameters.userIndicatorController)
|
||||
}
|
||||
|
||||
func start() {
|
||||
viewModel.actionsPublisher.sink { [weak self] action in
|
||||
MXLog.info("Coordinator: received view model action: \(action)")
|
||||
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .requestPassword:
|
||||
presentPasswordScreen()
|
||||
case .requestOIDCAuthorisation(let url):
|
||||
self.actionsSubject.send(.requestOIDCAuthorisation(url))
|
||||
case .resetFinished:
|
||||
self.actionsSubject.send(.resetFinished)
|
||||
case .cancel:
|
||||
self.actionsSubject.send(.cancel)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(EncryptionResetScreen(context: viewModel.context))
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func presentPasswordScreen() {
|
||||
let coordinator = EncryptionResetPasswordScreenCoordinator(parameters: .init())
|
||||
|
||||
coordinator.actionsPublisher.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
switch action {
|
||||
case .resetIdentity(let password):
|
||||
viewModel.continueResetFlowWith(password: password)
|
||||
parameters.navigationStackCoordinator.pop()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
parameters.navigationStackCoordinator.push(coordinator)
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum EncryptionResetScreenViewModelAction {
|
||||
case requestPassword
|
||||
case requestOIDCAuthorisation(url: URL)
|
||||
case resetFinished
|
||||
case cancel
|
||||
}
|
||||
|
||||
struct EncryptionResetScreenViewState: BindableState {
|
||||
private let listItem3AttributedText = {
|
||||
let boldPlaceholder = "{bold}"
|
||||
var finalString = AttributedString(L10n.screenCreateNewRecoveryKeyListItem3(boldPlaceholder))
|
||||
var boldString = AttributedString(L10n.screenCreateNewRecoveryKeyListItem3ResetAll)
|
||||
boldString.bold()
|
||||
finalString.replace(boldPlaceholder, with: boldString)
|
||||
return finalString
|
||||
}()
|
||||
|
||||
var listItems: [AttributedString] {
|
||||
[
|
||||
AttributedString(L10n.screenCreateNewRecoveryKeyListItem1(InfoPlistReader.main.productionAppName)),
|
||||
AttributedString(L10n.screenCreateNewRecoveryKeyListItem2),
|
||||
listItem3AttributedText,
|
||||
AttributedString(L10n.screenCreateNewRecoveryKeyListItem4),
|
||||
AttributedString(L10n.screenCreateNewRecoveryKeyListItem5)
|
||||
]
|
||||
}
|
||||
|
||||
var bindings: EncryptionResetScreenViewStateBindings
|
||||
}
|
||||
|
||||
struct EncryptionResetScreenViewStateBindings {
|
||||
var alertInfo: AlertInfo<UUID>?
|
||||
}
|
||||
|
||||
enum EncryptionResetScreenViewAction {
|
||||
case reset
|
||||
case cancel
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import MatrixRustSDK
|
||||
import SwiftUI
|
||||
|
||||
typealias EncryptionResetScreenViewModelType = StateStoreViewModel<EncryptionResetScreenViewState, EncryptionResetScreenViewAction>
|
||||
|
||||
class EncryptionResetScreenViewModel: EncryptionResetScreenViewModelType, EncryptionResetScreenViewModelProtocol {
|
||||
private let clientProxy: ClientProxyProtocol
|
||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||
|
||||
private let actionsSubject: PassthroughSubject<EncryptionResetScreenViewModelAction, Never> = .init()
|
||||
var actionsPublisher: AnyPublisher<EncryptionResetScreenViewModelAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private var identityResetHandle: IdentityResetHandle?
|
||||
|
||||
init(clientProxy: ClientProxyProtocol, userIndicatorController: UserIndicatorControllerProtocol) {
|
||||
self.clientProxy = clientProxy
|
||||
self.userIndicatorController = userIndicatorController
|
||||
|
||||
super.init(initialViewState: EncryptionResetScreenViewState(bindings: .init()))
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: EncryptionResetScreenViewAction) {
|
||||
switch viewAction {
|
||||
case .reset:
|
||||
state.bindings.alertInfo = .init(id: UUID(),
|
||||
title: L10n.screenResetEncryptionConfirmationAlertTitle,
|
||||
message: L10n.screenResetEncryptionConfirmationAlertSubtitle,
|
||||
primaryButton: .init(title: L10n.screenResetEncryptionConfirmationAlertAction, role: .destructive, action: { [weak self] in
|
||||
guard let self else { return }
|
||||
Task { await self.startResetFlow() }
|
||||
}))
|
||||
case .cancel:
|
||||
actionsSubject.send(.cancel)
|
||||
}
|
||||
}
|
||||
|
||||
func continueResetFlowWith(password: String) {
|
||||
Task {
|
||||
await resetWith(password: password)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func startResetFlow() async {
|
||||
showLoadingIndicator()
|
||||
|
||||
defer {
|
||||
hideLoadingIndicator()
|
||||
}
|
||||
|
||||
switch await clientProxy.resetIdentity() {
|
||||
case .success(let handle):
|
||||
// If the handle is missing then interactive authentication wasn't
|
||||
// necessary and the reset proceeded as normal
|
||||
guard let handle else {
|
||||
actionsSubject.send(.resetFinished)
|
||||
return
|
||||
}
|
||||
|
||||
identityResetHandle = handle
|
||||
|
||||
switch handle.authType() {
|
||||
case .uiaa:
|
||||
actionsSubject.send(.requestPassword)
|
||||
case .oidc(let oidcInfo):
|
||||
guard let url = URL(string: oidcInfo.approvalUrl) else {
|
||||
fatalError("Invalid URL received through identity reset handle: \(oidcInfo.approvalUrl)")
|
||||
}
|
||||
|
||||
hideLoadingIndicator()
|
||||
|
||||
actionsSubject.send(.requestOIDCAuthorisation(url: url))
|
||||
|
||||
await resetWithOIDCAuthorisation()
|
||||
}
|
||||
case .failure(let error):
|
||||
MXLog.error("Failed resetting encryption with error \(error)")
|
||||
showErrorToast()
|
||||
}
|
||||
}
|
||||
|
||||
func resetWith(password: String) async {
|
||||
guard let identityResetHandle else {
|
||||
fatalError("Requested reset flow continuation without a stored handle")
|
||||
}
|
||||
|
||||
showLoadingIndicator()
|
||||
|
||||
defer {
|
||||
hideLoadingIndicator()
|
||||
}
|
||||
|
||||
do {
|
||||
try await identityResetHandle.reset(auth: .password(passwordDetails: .init(identifier: clientProxy.userID, password: password)))
|
||||
actionsSubject.send(.resetFinished)
|
||||
} catch {
|
||||
MXLog.error("Failed resetting encryption with error \(error)")
|
||||
showErrorToast()
|
||||
}
|
||||
}
|
||||
|
||||
private func resetWithOIDCAuthorisation() async {
|
||||
guard let identityResetHandle else {
|
||||
fatalError("Requested reset flow continuation without a stored handle")
|
||||
}
|
||||
|
||||
do {
|
||||
try await identityResetHandle.reset(auth: nil)
|
||||
actionsSubject.send(.resetFinished)
|
||||
} catch {
|
||||
MXLog.error("Failed resetting encryption with error \(error)")
|
||||
showErrorToast()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Toasts and loading indicators
|
||||
|
||||
private static let loadingIndicatorIdentifier = "\(EncryptionResetScreenViewModel.self)-Loading"
|
||||
|
||||
private func showLoadingIndicator() {
|
||||
userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier,
|
||||
type: .modal,
|
||||
title: L10n.commonLoading,
|
||||
persistent: true))
|
||||
}
|
||||
|
||||
private func hideLoadingIndicator() {
|
||||
userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier)
|
||||
}
|
||||
|
||||
private func showErrorToast() {
|
||||
userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown))
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
protocol EncryptionResetScreenViewModelProtocol {
|
||||
var actionsPublisher: AnyPublisher<EncryptionResetScreenViewModelAction, Never> { get }
|
||||
var context: EncryptionResetScreenViewModelType.Context { get }
|
||||
|
||||
func continueResetFlowWith(password: String)
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct EncryptionResetScreen: View {
|
||||
@ObservedObject var context: EncryptionResetScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
FullscreenDialog {
|
||||
mainContent
|
||||
} bottomContent: {
|
||||
Button(L10n.actionContinue, role: .destructive) {
|
||||
context.send(viewAction: .reset)
|
||||
}
|
||||
.buttonStyle(.compound(.primary))
|
||||
}
|
||||
.background()
|
||||
.backgroundStyle(.compound.bgSubtleSecondary)
|
||||
.interactiveDismissDisabled()
|
||||
.toolbar { toolbar }
|
||||
.toolbar(.visible, for: .navigationBar)
|
||||
.alert(item: $context.alertInfo)
|
||||
}
|
||||
|
||||
/// The main content of the screen that is shown inside the scroll view.
|
||||
private var mainContent: some View {
|
||||
VStack(spacing: 24) {
|
||||
header
|
||||
checkmarkList
|
||||
footer
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(spacing: 8) {
|
||||
HeroImage(icon: \.error, style: .critical)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
Text(L10n.screenEncryptionResetTitle)
|
||||
.font(.compound.headingMDBold)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
|
||||
Text(L10n.screenEncryptionResetSubtitle)
|
||||
.font(.compound.bodyMD)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
Text(L10n.screenEncryptionResetFooter)
|
||||
.font(.compound.bodyMDSemibold)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
}
|
||||
|
||||
/// The list of re-assurances about analytics.
|
||||
private var checkmarkList: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
checkMarkItem(title: L10n.screenEncryptionResetBullet1, position: .top, positive: true)
|
||||
checkMarkItem(title: L10n.screenEncryptionResetBullet2, position: .middle, positive: false)
|
||||
checkMarkItem(title: L10n.screenEncryptionResetBullet3, position: .bottom, positive: false)
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity)
|
||||
.environment(\.backgroundStyle, AnyShapeStyle(.compound.bgSubtleSecondary))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func checkMarkItem(title: String, position: ListPosition, positive: Bool) -> some View {
|
||||
RoundedLabelItem(title: title, listPosition: position) {
|
||||
if positive {
|
||||
CompoundIcon(\.check)
|
||||
.foregroundColor(.compound.iconAccentPrimary)
|
||||
} else {
|
||||
CompoundIcon(\.close)
|
||||
.foregroundColor(.compound.iconCriticalPrimary)
|
||||
}
|
||||
}
|
||||
.backgroundStyle(.compound.bgCanvasDefault)
|
||||
}
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(L10n.actionCancel) {
|
||||
context.send(viewAction: .cancel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct EncryptionResetScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = EncryptionResetScreenViewModel(clientProxy: ClientProxyMock(.init()),
|
||||
userIndicatorController: UserIndicatorControllerMock())
|
||||
static var previews: some View {
|
||||
NavigationStack {
|
||||
EncryptionResetScreen(context: viewModel.context)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user