mirror of
https://gitlab.opencode.de/bwi/bundesmessenger/clients/bundesmessenger-ios.git
synced 2026-04-17 15:09:31 +02:00
Merge pull request #6243 from vector-im/release/1.8.18/release
Release 1.8.18
This commit is contained in:
2
.github/workflows/ci-build.yml
vendored
2
.github/workflows/ci-build.yml
vendored
@@ -15,7 +15,7 @@ env:
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: macos-11
|
||||
runs-on: macos-12
|
||||
|
||||
# Concurrency group not needed as this workflow only runs on develop which we always want to test.
|
||||
|
||||
|
||||
2
.github/workflows/ci-tests.yml
vendored
2
.github/workflows/ci-tests.yml
vendored
@@ -16,7 +16,7 @@ env:
|
||||
jobs:
|
||||
tests:
|
||||
name: Tests
|
||||
runs-on: macos-11
|
||||
runs-on: macos-12
|
||||
|
||||
concurrency:
|
||||
# When running on develop, use the sha to allow all runs of this workflow to run concurrently.
|
||||
|
||||
2
.github/workflows/ci-ui-tests.yml
vendored
2
.github/workflows/ci-ui-tests.yml
vendored
@@ -14,7 +14,7 @@ env:
|
||||
jobs:
|
||||
tests:
|
||||
name: UI Tests
|
||||
runs-on: macos-11
|
||||
runs-on: macos-12
|
||||
|
||||
concurrency:
|
||||
# Only allow a single run of this workflow on each branch, automatically cancelling older runs.
|
||||
|
||||
4
.github/workflows/release-alpha.yml
vendored
4
.github/workflows/release-alpha.yml
vendored
@@ -14,7 +14,7 @@ env:
|
||||
|
||||
jobs:
|
||||
check-secret:
|
||||
runs-on: macos-11
|
||||
runs-on: macos-12
|
||||
outputs:
|
||||
out-key: ${{ steps.out-key.outputs.defined }}
|
||||
steps:
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
needs: [check-secret]
|
||||
if: needs.check-secret.outputs.out-key == 'true'
|
||||
name: Release
|
||||
runs-on: macos-11
|
||||
runs-on: macos-12
|
||||
|
||||
concurrency:
|
||||
# Only allow a single run of this workflow on each branch, automatically cancelling older runs.
|
||||
|
||||
53
.github/workflows/triage-move-labelled.yml
vendored
53
.github/workflows/triage-move-labelled.yml
vendored
@@ -9,15 +9,15 @@ jobs:
|
||||
name: Add Z-Labs label for features behind labs flags
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
contains(github.event.issue.labels.*.name, 'A-Maths') ||
|
||||
contains(github.event.issue.labels.*.name, 'A-Message-Pinning') ||
|
||||
contains(github.event.issue.labels.*.name, 'A-Polls') ||
|
||||
contains(github.event.issue.labels.*.name, 'A-Location-Sharing') ||
|
||||
contains(github.event.issue.labels.*.name, 'A-Message-Bubbles') ||
|
||||
contains(github.event.issue.labels.*.name, 'Z-IA') ||
|
||||
contains(github.event.issue.labels.*.name, 'A-Themes-Custom') ||
|
||||
contains(github.event.issue.labels.*.name, 'A-E2EE-Dehydration') ||
|
||||
contains(github.event.issue.labels.*.name, 'A-Tags')
|
||||
contains(github.event.issue.labels.*.name, 'A-Maths') ||
|
||||
contains(github.event.issue.labels.*.name, 'A-Message-Pinning') ||
|
||||
contains(github.event.issue.labels.*.name, 'A-Polls') ||
|
||||
contains(github.event.issue.labels.*.name, 'A-Location-Sharing') ||
|
||||
contains(github.event.issue.labels.*.name, 'A-Message-Bubbles') ||
|
||||
contains(github.event.issue.labels.*.name, 'Z-IA') ||
|
||||
contains(github.event.issue.labels.*.name, 'A-Themes-Custom') ||
|
||||
contains(github.event.issue.labels.*.name, 'A-E2EE-Dehydration') ||
|
||||
contains(github.event.issue.labels.*.name, 'A-Tags')
|
||||
steps:
|
||||
- uses: actions/github-script@v5
|
||||
with:
|
||||
@@ -44,14 +44,14 @@ jobs:
|
||||
name: P1 X-Needs-Design to Design project board
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
contains(github.event.issue.labels.*.name, 'X-Needs-Design') &&
|
||||
(contains(github.event.issue.labels.*.name, 'S-Critical') &&
|
||||
(contains(github.event.issue.labels.*.name, 'O-Frequent') ||
|
||||
contains(github.event.issue.labels.*.name, 'O-Occasional')) ||
|
||||
contains(github.event.issue.labels.*.name, 'S-Major') &&
|
||||
contains(github.event.issue.labels.*.name, 'O-Frequent') ||
|
||||
contains(github.event.issue.labels.*.name, 'A11y') &&
|
||||
contains(github.event.issue.labels.*.name, 'O-Frequent'))
|
||||
contains(github.event.issue.labels.*.name, 'X-Needs-Design') &&
|
||||
(contains(github.event.issue.labels.*.name, 'S-Critical') &&
|
||||
(contains(github.event.issue.labels.*.name, 'O-Frequent') ||
|
||||
contains(github.event.issue.labels.*.name, 'O-Occasional')) ||
|
||||
contains(github.event.issue.labels.*.name, 'S-Major') &&
|
||||
contains(github.event.issue.labels.*.name, 'O-Frequent') ||
|
||||
contains(github.event.issue.labels.*.name, 'A11y') &&
|
||||
contains(github.event.issue.labels.*.name, 'O-Frequent'))
|
||||
steps:
|
||||
- uses: octokit/graphql-action@v2.x
|
||||
id: add_to_project
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
name: X-Needs-Product to Design project board
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
contains(github.event.issue.labels.*.name, 'X-Needs-Product')
|
||||
contains(github.event.issue.labels.*.name, 'X-Needs-Product')
|
||||
steps:
|
||||
- uses: octokit/graphql-action@v2.x
|
||||
id: add_to_project
|
||||
@@ -99,10 +99,7 @@ jobs:
|
||||
name: Spaces issues to Delight project board
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
contains(github.event.issue.labels.*.name, 'A-Spaces') ||
|
||||
contains(github.event.issue.labels.*.name, 'A-Space-Settings') ||
|
||||
contains(github.event.issue.labels.*.name, 'A-Subspaces') ||
|
||||
contains(github.event.issue.labels.*.name, 'Z-IA')
|
||||
contains(github.event.issue.labels.*.name, 'Team: Delight')
|
||||
steps:
|
||||
- uses: octokit/graphql-action@v2.x
|
||||
with:
|
||||
@@ -125,7 +122,7 @@ jobs:
|
||||
name: A-Voice Messages to voice message board
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
contains(github.event.issue.labels.*.name, 'A-Voice Messages')
|
||||
contains(github.event.issue.labels.*.name, 'A-Voice Messages')
|
||||
steps:
|
||||
- uses: octokit/graphql-action@v2.x
|
||||
with:
|
||||
@@ -148,7 +145,7 @@ jobs:
|
||||
name: A-Threads to Thread board
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
contains(github.event.issue.labels.*.name, 'A-Threads')
|
||||
contains(github.event.issue.labels.*.name, 'A-Threads')
|
||||
steps:
|
||||
- uses: octokit/graphql-action@v2.x
|
||||
with:
|
||||
@@ -171,7 +168,7 @@ jobs:
|
||||
name: A-Message-Bubbles to Message bubble board
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
contains(github.event.issue.labels.*.name, 'A-Message-Bubbles')
|
||||
contains(github.event.issue.labels.*.name, 'A-Message-Bubbles')
|
||||
steps:
|
||||
- uses: octokit/graphql-action@v2.x
|
||||
with:
|
||||
@@ -194,7 +191,7 @@ jobs:
|
||||
name: Z-FTUE to FTUE board
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
contains(github.event.issue.labels.*.name, 'Z-FTUE')
|
||||
contains(github.event.issue.labels.*.name, 'Z-FTUE')
|
||||
steps:
|
||||
- uses: octokit/graphql-action@v2.x
|
||||
with:
|
||||
@@ -212,12 +209,12 @@ jobs:
|
||||
env:
|
||||
PROJECT_ID: "PN_kwDOAM0swc4AAqVx"
|
||||
GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
|
||||
|
||||
move_WTF_issues:
|
||||
name: Z-WTF to WTF board
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
contains(github.event.issue.labels.*.name, 'Z-WTF')
|
||||
contains(github.event.issue.labels.*.name, 'Z-WTF')
|
||||
steps:
|
||||
- uses: octokit/graphql-action@v2.x
|
||||
with:
|
||||
|
||||
57
.github/workflows/triage-priority-bugs.yml
vendored
57
.github/workflows/triage-priority-bugs.yml
vendored
@@ -8,22 +8,19 @@ jobs:
|
||||
p1_issues_to_team_workboard:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
(!contains(github.event.issue.labels.*.name, 'A-E2EE') &&
|
||||
!contains(github.event.issue.labels.*.name, 'A-E2EE-Cross-Signing') &&
|
||||
!contains(github.event.issue.labels.*.name, 'A-E2EE-Dehydration') &&
|
||||
!contains(github.event.issue.labels.*.name, 'A-E2EE-Key-Backup') &&
|
||||
!contains(github.event.issue.labels.*.name, 'A-E2EE-SAS-Verification') &&
|
||||
!contains(github.event.issue.labels.*.name, 'A-Spaces') &&
|
||||
!contains(github.event.issue.labels.*.name, 'A-Spaces-Settings') &&
|
||||
!contains(github.event.issue.labels.*.name, 'A-Subspaces')) &&
|
||||
(contains(github.event.issue.labels.*.name, 'T-Defect') &&
|
||||
contains(github.event.issue.labels.*.name, 'S-Critical') &&
|
||||
(contains(github.event.issue.labels.*.name, 'O-Frequent') ||
|
||||
contains(github.event.issue.labels.*.name, 'O-Occasional')) ||
|
||||
contains(github.event.issue.labels.*.name, 'S-Major') &&
|
||||
contains(github.event.issue.labels.*.name, 'O-Frequent') ||
|
||||
contains(github.event.issue.labels.*.name, 'A11y') &&
|
||||
contains(github.event.issue.labels.*.name, 'O-Frequent'))
|
||||
(!contains(github.event.issue.labels.*.name, 'A-E2EE') &&
|
||||
!contains(github.event.issue.labels.*.name, 'A-E2EE-Cross-Signing') &&
|
||||
!contains(github.event.issue.labels.*.name, 'A-E2EE-Dehydration') &&
|
||||
!contains(github.event.issue.labels.*.name, 'A-E2EE-Key-Backup') &&
|
||||
!contains(github.event.issue.labels.*.name, 'A-E2EE-SAS-Verification') &&
|
||||
(contains(github.event.issue.labels.*.name, 'T-Defect') &&
|
||||
contains(github.event.issue.labels.*.name, 'S-Critical') &&
|
||||
(contains(github.event.issue.labels.*.name, 'O-Frequent') ||
|
||||
contains(github.event.issue.labels.*.name, 'O-Occasional')) ||
|
||||
contains(github.event.issue.labels.*.name, 'S-Major') &&
|
||||
contains(github.event.issue.labels.*.name, 'O-Frequent') ||
|
||||
contains(github.event.issue.labels.*.name, 'A11y') &&
|
||||
contains(github.event.issue.labels.*.name, 'O-Frequent'))
|
||||
steps:
|
||||
- uses: alex-page/github-project-automation-plus@bb266ff4dde9242060e2d5418e120a133586d488
|
||||
with:
|
||||
@@ -34,20 +31,20 @@ jobs:
|
||||
P1_issues_to_crypto_team_workboard:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
contains(github.event.issue.labels.*.name, 'Z-UISI') ||
|
||||
(contains(github.event.issue.labels.*.name, 'A-E2EE') ||
|
||||
contains(github.event.issue.labels.*.name, 'A-E2EE-Cross-Signing') ||
|
||||
contains(github.event.issue.labels.*.name, 'A-E2EE-Dehydration') ||
|
||||
contains(github.event.issue.labels.*.name, 'A-E2EE-Key-Backup') ||
|
||||
contains(github.event.issue.labels.*.name, 'A-E2EE-SAS-Verification')) &&
|
||||
(contains(github.event.issue.labels.*.name, 'T-Defect') &&
|
||||
contains(github.event.issue.labels.*.name, 'S-Critical') &&
|
||||
(contains(github.event.issue.labels.*.name, 'O-Frequent') ||
|
||||
contains(github.event.issue.labels.*.name, 'O-Occasional')) ||
|
||||
contains(github.event.issue.labels.*.name, 'S-Major') &&
|
||||
contains(github.event.issue.labels.*.name, 'O-Frequent') ||
|
||||
contains(github.event.issue.labels.*.name, 'A11y') &&
|
||||
contains(github.event.issue.labels.*.name, 'O-Frequent'))
|
||||
contains(github.event.issue.labels.*.name, 'Z-UISI') ||
|
||||
(contains(github.event.issue.labels.*.name, 'A-E2EE') ||
|
||||
contains(github.event.issue.labels.*.name, 'A-E2EE-Cross-Signing') ||
|
||||
contains(github.event.issue.labels.*.name, 'A-E2EE-Dehydration') ||
|
||||
contains(github.event.issue.labels.*.name, 'A-E2EE-Key-Backup') ||
|
||||
contains(github.event.issue.labels.*.name, 'A-E2EE-SAS-Verification')) &&
|
||||
(contains(github.event.issue.labels.*.name, 'T-Defect') &&
|
||||
contains(github.event.issue.labels.*.name, 'S-Critical') &&
|
||||
(contains(github.event.issue.labels.*.name, 'O-Frequent') ||
|
||||
contains(github.event.issue.labels.*.name, 'O-Occasional')) ||
|
||||
contains(github.event.issue.labels.*.name, 'S-Major') &&
|
||||
contains(github.event.issue.labels.*.name, 'O-Frequent') ||
|
||||
contains(github.event.issue.labels.*.name, 'A11y') &&
|
||||
contains(github.event.issue.labels.*.name, 'O-Frequent'))
|
||||
steps:
|
||||
- uses: alex-page/github-project-automation-plus@bb266ff4dde9242060e2d5418e120a133586d488
|
||||
with:
|
||||
|
||||
23
CHANGES.md
23
CHANGES.md
@@ -1,3 +1,26 @@
|
||||
## Changes in 1.8.18 (2022-06-03)
|
||||
|
||||
🙌 Improvements
|
||||
|
||||
- Upgrade MatrixSDK version ([v0.23.8](https://github.com/matrix-org/matrix-ios-sdk/releases/tag/v0.23.8)).
|
||||
- Show user indicators when paginating a room ([#5746](https://github.com/vector-im/element-ios/issues/5746))
|
||||
- Authentication: Display fallback screens on registration & login according to the HS needs. ([#6176](https://github.com/vector-im/element-ios/issues/6176))
|
||||
- WellKnown: support outbound keys presharing strategy ([#6214](https://github.com/vector-im/element-ios/issues/6214))
|
||||
|
||||
🐛 Bugfixes
|
||||
|
||||
- Location sharing: Improve automatic detection of pin drop state ([#6202](https://github.com/vector-im/element-ios/issues/6202))
|
||||
|
||||
🧱 Build
|
||||
|
||||
- Ensure that warnings from CocoaPods dependencies do not show up in Xcode ([#6196](https://github.com/vector-im/element-ios/pull/6196))
|
||||
- CI: Use macOS 12 and Xcode 13.4 ([#6204](https://github.com/vector-im/element-ios/pull/6204))
|
||||
|
||||
🚧 In development 🚧
|
||||
|
||||
- Authentication: Add the login screen to the new flow and support SSO on both login and registration flows. ([#5654](https://github.com/vector-im/element-ios/issues/5654))
|
||||
|
||||
|
||||
## Changes in 1.8.17 (2022-05-31)
|
||||
|
||||
🙌 Improvements
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
//
|
||||
|
||||
// Version
|
||||
MARKETING_VERSION = 1.8.17
|
||||
CURRENT_PROJECT_VERSION = 1.8.17
|
||||
MARKETING_VERSION = 1.8.18
|
||||
CURRENT_PROJECT_VERSION = 1.8.18
|
||||
|
||||
16
Podfile
16
Podfile
@@ -3,6 +3,9 @@ source 'https://cdn.cocoapods.org/'
|
||||
# Uncomment this line to define a global platform for your project
|
||||
platform :ios, '14.0'
|
||||
|
||||
# By default, ignore all warnings from any pod
|
||||
inhibit_all_warnings!
|
||||
|
||||
# Use frameworks to allow usage of pods written in Swift
|
||||
use_frameworks!
|
||||
|
||||
@@ -13,7 +16,7 @@ use_frameworks!
|
||||
# - `{ :specHash => {sdk spec hash}` to depend on specific pod options (:git => …, :podspec => …) for MatrixSDK repo. Used by Fastfile during CI
|
||||
#
|
||||
# Warning: our internal tooling depends on the name of this variable name, so be sure not to change it
|
||||
$matrixSDKVersion = '= 0.23.7'
|
||||
$matrixSDKVersion = '= 0.23.8'
|
||||
# $matrixSDKVersion = :local
|
||||
# $matrixSDKVersion = { :branch => 'develop'}
|
||||
# $matrixSDKVersion = { :specHash => { git: 'https://git.io/fork123', branch: 'fix' } }
|
||||
@@ -42,8 +45,8 @@ end
|
||||
|
||||
# Method to import the MatrixSDK
|
||||
def import_MatrixSDK
|
||||
pod 'MatrixSDK', $matrixSDKVersionSpec
|
||||
pod 'MatrixSDK/JingleCallStack', $matrixSDKVersionSpec
|
||||
pod 'MatrixSDK', $matrixSDKVersionSpec, :inhibit_warnings => false
|
||||
pod 'MatrixSDK/JingleCallStack', $matrixSDKVersionSpec, :inhibit_warnings => false
|
||||
end
|
||||
|
||||
########################################
|
||||
@@ -69,12 +72,11 @@ abstract_target 'RiotPods' do
|
||||
|
||||
# PostHog for analytics
|
||||
pod 'PostHog', '~> 1.4.4'
|
||||
pod 'AnalyticsEvents', :git => 'https://github.com/matrix-org/matrix-analytics-events.git', :branch => 'release/swift'
|
||||
pod 'AnalyticsEvents', :git => 'https://github.com/matrix-org/matrix-analytics-events.git', :branch => 'release/swift', :inhibit_warnings => false
|
||||
# pod 'AnalyticsEvents', :path => '../matrix-analytics-events/AnalyticsEvents.podspec'
|
||||
|
||||
# Remove warnings from "bad" pods
|
||||
pod 'OLMKit', :inhibit_warnings => true
|
||||
pod 'zxcvbn-ios', :inhibit_warnings => true
|
||||
pod 'OLMKit'
|
||||
pod 'zxcvbn-ios'
|
||||
|
||||
# Tools
|
||||
pod 'SwiftGen', '~> 6.3'
|
||||
|
||||
@@ -229,6 +229,6 @@ SPEC CHECKSUMS:
|
||||
zxcvbn-ios: fef98b7c80f1512ff0eec47ac1fa399fc00f7e3c
|
||||
ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb
|
||||
|
||||
PODFILE CHECKSUM: 525efbc379e42474c461991425266c166593d69f
|
||||
PODFILE CHECKSUM: 37d7f07953015898ac6f2e628e9b20c2bdd55753
|
||||
|
||||
COCOAPODS: 1.11.2
|
||||
COCOAPODS: 1.11.3
|
||||
|
||||
@@ -23,13 +23,17 @@
|
||||
// MARK: Onboarding Authentication WIP
|
||||
"authentication_registration_title" = "Create your account";
|
||||
"authentication_registration_message" = "We’ll need some info to get you set up.";
|
||||
"authentication_registration_server_title" = "Choose your server to store your data";
|
||||
"authentication_registration_matrix_description" = "Join millions for free on the largest public server";
|
||||
"authentication_registration_username" = "Username";
|
||||
"authentication_registration_password" = "Password";
|
||||
"authentication_registration_username_footer" = "You can’t change this later";
|
||||
"authentication_registration_password_footer" = "Must be 8 characters or more";
|
||||
|
||||
"authentication_login_title" = "Welcome back!";
|
||||
"authentication_login_username" = "Username or Email";
|
||||
"authentication_login_forgot_password" = "Forgot password";
|
||||
|
||||
"authentication_server_info_title" = "Choose your server to store your data";
|
||||
"authentication_server_info_matrix_description" = "Join millions for free on the largest public server";
|
||||
|
||||
"authentication_server_selection_title" = "Choose your server";
|
||||
"authentication_server_selection_message" = "What is the address of your server? A server is like a home for all your data.";
|
||||
"authentication_server_selection_server_url" = "Server URL";
|
||||
|
||||
@@ -16,16 +16,19 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
struct HomeserverAddress {
|
||||
/// Ensures the address contains a scheme, otherwise makes it `https`.
|
||||
static func sanitized(_ address: String) -> String {
|
||||
!address.contains("://") ? "https://\(address.lowercased())" : address.lowercased()
|
||||
}
|
||||
|
||||
/// Strips the `https://` away from the address (but leaves `http://`) for display in labels.
|
||||
///
|
||||
/// `http://` is left in the string to make it clear when a chosen server doesn't use SSL.
|
||||
static func displayable(_ address: String) -> String {
|
||||
address.replacingOccurrences(of: "https://", with: "")
|
||||
extension MXKKeyPreSharingStrategy {
|
||||
init?(key: String?) {
|
||||
guard let key = key else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch key {
|
||||
case "on_typing":
|
||||
self = .whenTyping
|
||||
case "on_room_opening":
|
||||
self = .whenEnteringRoom
|
||||
default:
|
||||
self = .none
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,4 +34,8 @@ import UIKit
|
||||
return userInterfaceIdiom == .phone
|
||||
}
|
||||
|
||||
var initialDisplayName: String {
|
||||
isPhone ? VectorL10n.loginMobileDevice : VectorL10n.loginTabletDevice
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -14,30 +14,30 @@ public extension VectorL10n {
|
||||
static var authenticationCancelFlowConfirmationMessage: String {
|
||||
return VectorL10n.tr("Untranslated", "authentication_cancel_flow_confirmation_message")
|
||||
}
|
||||
/// Forgot password
|
||||
static var authenticationLoginForgotPassword: String {
|
||||
return VectorL10n.tr("Untranslated", "authentication_login_forgot_password")
|
||||
}
|
||||
/// Welcome back!
|
||||
static var authenticationLoginTitle: String {
|
||||
return VectorL10n.tr("Untranslated", "authentication_login_title")
|
||||
}
|
||||
/// Username or Email
|
||||
static var authenticationLoginUsername: String {
|
||||
return VectorL10n.tr("Untranslated", "authentication_login_username")
|
||||
}
|
||||
/// This server would like to make sure you are not a robot
|
||||
static var authenticationRecaptchaMessage: String {
|
||||
return VectorL10n.tr("Untranslated", "authentication_recaptcha_message")
|
||||
}
|
||||
/// Join millions for free on the largest public server
|
||||
static var authenticationRegistrationMatrixDescription: String {
|
||||
return VectorL10n.tr("Untranslated", "authentication_registration_matrix_description")
|
||||
}
|
||||
/// We’ll need some info to get you set up.
|
||||
static var authenticationRegistrationMessage: String {
|
||||
return VectorL10n.tr("Untranslated", "authentication_registration_message")
|
||||
}
|
||||
/// Password
|
||||
static var authenticationRegistrationPassword: String {
|
||||
return VectorL10n.tr("Untranslated", "authentication_registration_password")
|
||||
}
|
||||
/// Must be 8 characters or more
|
||||
static var authenticationRegistrationPasswordFooter: String {
|
||||
return VectorL10n.tr("Untranslated", "authentication_registration_password_footer")
|
||||
}
|
||||
/// Choose your server to store your data
|
||||
static var authenticationRegistrationServerTitle: String {
|
||||
return VectorL10n.tr("Untranslated", "authentication_registration_server_title")
|
||||
}
|
||||
/// Create your account
|
||||
static var authenticationRegistrationTitle: String {
|
||||
return VectorL10n.tr("Untranslated", "authentication_registration_title")
|
||||
@@ -50,6 +50,14 @@ public extension VectorL10n {
|
||||
static var authenticationRegistrationUsernameFooter: String {
|
||||
return VectorL10n.tr("Untranslated", "authentication_registration_username_footer")
|
||||
}
|
||||
/// Join millions for free on the largest public server
|
||||
static var authenticationServerInfoMatrixDescription: String {
|
||||
return VectorL10n.tr("Untranslated", "authentication_server_info_matrix_description")
|
||||
}
|
||||
/// Choose your server to store your data
|
||||
static var authenticationServerInfoTitle: String {
|
||||
return VectorL10n.tr("Untranslated", "authentication_server_info_title")
|
||||
}
|
||||
/// Cannot find a server at this URL, please check it is correct.
|
||||
static var authenticationServerSelectionGenericError: String {
|
||||
return VectorL10n.tr("Untranslated", "authentication_server_selection_generic_error")
|
||||
|
||||
@@ -41,6 +41,8 @@ final class HomeserverConfigurationBuilder: NSObject {
|
||||
let isE2EEByDefaultEnabled = vectorWellKnownEncryptionConfiguration?.isE2EEByDefaultEnabled ?? true
|
||||
// Disable mandatory secure backup when there is no value
|
||||
let isSecureBackupRequired = vectorWellKnownEncryptionConfiguration?.isSecureBackupRequired ?? false
|
||||
// Default to `MXKKeyPreSharingWhenTyping` when there is no value
|
||||
let outboundKeysPreSharingMode = vectorWellKnownEncryptionConfiguration?.outboundKeysPreSharingMode ?? .whenTyping
|
||||
// Defaults to all secure backup methods available when there is no value
|
||||
let secureBackupSetupMethods: [VectorWellKnownBackupSetupMethod]
|
||||
if let backupSetupMethods = vectorWellKnownEncryptionConfiguration?.secureBackupSetupMethods {
|
||||
@@ -51,7 +53,8 @@ final class HomeserverConfigurationBuilder: NSObject {
|
||||
|
||||
let encryptionConfiguration = HomeserverEncryptionConfiguration(isE2EEByDefaultEnabled: isE2EEByDefaultEnabled,
|
||||
isSecureBackupRequired: isSecureBackupRequired,
|
||||
secureBackupSetupMethods: secureBackupSetupMethods)
|
||||
secureBackupSetupMethods: secureBackupSetupMethods,
|
||||
outboundKeysPreSharingMode: outboundKeysPreSharingMode)
|
||||
|
||||
// Jitsi configuration
|
||||
let jitsiPreferredDomain: String?
|
||||
|
||||
@@ -22,12 +22,15 @@ final class HomeserverEncryptionConfiguration: NSObject {
|
||||
let isE2EEByDefaultEnabled: Bool
|
||||
let isSecureBackupRequired: Bool
|
||||
let secureBackupSetupMethods: [VectorWellKnownBackupSetupMethod]
|
||||
let outboundKeysPreSharingMode: MXKKeyPreSharingStrategy
|
||||
|
||||
init(isE2EEByDefaultEnabled: Bool,
|
||||
isSecureBackupRequired: Bool,
|
||||
secureBackupSetupMethods: [VectorWellKnownBackupSetupMethod]) {
|
||||
secureBackupSetupMethods: [VectorWellKnownBackupSetupMethod],
|
||||
outboundKeysPreSharingMode: MXKKeyPreSharingStrategy) {
|
||||
self.isE2EEByDefaultEnabled = isE2EEByDefaultEnabled
|
||||
self.isSecureBackupRequired = isSecureBackupRequired
|
||||
self.outboundKeysPreSharingMode = outboundKeysPreSharingMode
|
||||
self.secureBackupSetupMethods = secureBackupSetupMethods
|
||||
|
||||
super.init()
|
||||
|
||||
@@ -48,6 +48,8 @@ struct VectorWellKnownEncryptionConfiguration {
|
||||
let isSecureBackupRequired: Bool?
|
||||
/// Methods to use to setup secure backup (SSSS).
|
||||
let secureBackupSetupMethods: [VectorWellKnownBackupSetupMethod]?
|
||||
/// Outbound keys pre sharing strategy.
|
||||
let outboundKeysPreSharingMode: MXKKeyPreSharingStrategy?
|
||||
}
|
||||
|
||||
extension VectorWellKnownEncryptionConfiguration: Decodable {
|
||||
@@ -56,6 +58,7 @@ extension VectorWellKnownEncryptionConfiguration: Decodable {
|
||||
case isE2EEByDefaultEnabled = "default"
|
||||
case isSecureBackupRequired = "secure_backup_required"
|
||||
case secureBackupSetupMethods = "secure_backup_setup_methods"
|
||||
case outboundKeysPreSharingMode = "outbound_keys_pre_sharing_mode"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
@@ -64,6 +67,8 @@ extension VectorWellKnownEncryptionConfiguration: Decodable {
|
||||
isSecureBackupRequired = try? container.decode(Bool.self, forKey: .isSecureBackupRequired)
|
||||
let secureBackupSetupMethodsKeys = try? container.decode([String].self, forKey: .secureBackupSetupMethods)
|
||||
secureBackupSetupMethods = secureBackupSetupMethodsKeys?.compactMap { VectorWellKnownBackupSetupMethod(key: $0) }
|
||||
let outboundKeysPreSharingModeKey = try? container.decode(String.self, forKey: .outboundKeysPreSharingMode)
|
||||
outboundKeysPreSharingMode = MXKKeyPreSharingStrategy(key: outboundKeysPreSharingModeKey)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -189,8 +189,8 @@ final class AppCoordinator: NSObject, AppCoordinatorType {
|
||||
let canOpenLink: Bool
|
||||
|
||||
switch deepLinkOption {
|
||||
case .connect(let loginToken, let transactionId):
|
||||
canOpenLink = self.legacyAppDelegate.continueSSOLogin(withToken: loginToken, txnId: transactionId)
|
||||
case .connect(let loginToken, let transactionID):
|
||||
canOpenLink = AuthenticationService.shared.continueSSOLogin(with: loginToken, and: transactionID)
|
||||
}
|
||||
|
||||
return canOpenLink
|
||||
|
||||
@@ -282,14 +282,6 @@ UINavigationControllerDelegate
|
||||
*/
|
||||
- (void)checkAppVersion;
|
||||
|
||||
#pragma mark - Authentication
|
||||
|
||||
/// When SSO login succeeded, when SFSafariViewController is used, continue login with success parameters.
|
||||
/// @param loginToken The login token provided when SSO succeeded.
|
||||
/// @param txnId transaction id generated during SSO page presentation.
|
||||
/// returns YES if the SSO login can be continued.
|
||||
- (BOOL)continueSSOLoginWithToken:(NSString*)loginToken txnId:(NSString*)txnId;
|
||||
|
||||
@end
|
||||
|
||||
@protocol LegacyAppDelegateDelegate <NSObject>
|
||||
|
||||
@@ -2430,6 +2430,11 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni
|
||||
}
|
||||
|
||||
BOOL isLaunching = NO;
|
||||
|
||||
if (mainSession.vc_homeserverConfiguration)
|
||||
{
|
||||
[MXKAppSettings standardAppSettings].outboundGroupSessionKeyPreSharingStrategy = mainSession.vc_homeserverConfiguration.encryption.outboundKeysPreSharingMode;
|
||||
}
|
||||
|
||||
if (_masterTabBarController.isOnboardingInProgress)
|
||||
{
|
||||
@@ -4696,21 +4701,6 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni
|
||||
[self presentViewController:viewController animated:YES completion:completion];
|
||||
}
|
||||
|
||||
#pragma mark - Authentication
|
||||
|
||||
- (BOOL)continueSSOLoginWithToken:(NSString*)loginToken txnId:(NSString*)txnId
|
||||
{
|
||||
OnboardingCoordinatorBridgePresenter *bridgePresenter = self.masterTabBarController.onboardingCoordinatorBridgePresenter;
|
||||
|
||||
if (!bridgePresenter)
|
||||
{
|
||||
MXLogDebug(@"[AppDelegate] Fail to continue SSO login");
|
||||
return NO;
|
||||
}
|
||||
|
||||
return [bridgePresenter continueSSOLoginWithToken:loginToken transactionID:txnId];
|
||||
}
|
||||
|
||||
#pragma mark - Private
|
||||
|
||||
- (void)clearCache
|
||||
|
||||
@@ -50,9 +50,6 @@ protocol AuthenticationCoordinatorProtocol: Coordinator, Presentable {
|
||||
/// Set up the authentication screen with the specified homeserver and/or identity server.
|
||||
func updateHomeserver(_ homeserver: String?, andIdentityServer identityServer: String?)
|
||||
|
||||
/// When SSO login succeeded, when SFSafariViewController is used, continue login with success parameters.
|
||||
func continueSSOLogin(withToken loginToken: String, transactionID: String) -> Bool
|
||||
|
||||
/// Indicates to the coordinator to display any pending screens if it was created with
|
||||
/// the `canPresentAdditionalScreens` parameter set to `false`
|
||||
func presentPendingScreensIfNecessary()
|
||||
|
||||
@@ -554,6 +554,12 @@ static const CGFloat kAuthInputContainerViewMinHeightConstraintConstant = 150.0;
|
||||
|
||||
- (BOOL)continueSSOLoginWithToken:(NSString*)loginToken txnId:(NSString*)txnId
|
||||
{
|
||||
// The presenter isn't dismissed automatically when finishing via a deep link
|
||||
if (self.ssoAuthenticationPresenter)
|
||||
{
|
||||
[self dismissSSOAuthenticationPresenter];
|
||||
}
|
||||
|
||||
// Check if transaction id is the same as expected
|
||||
if (loginToken &&
|
||||
txnId && self.ssoCallbackTxnId
|
||||
|
||||
@@ -73,8 +73,10 @@ final class LegacyAuthenticationCoordinator: NSObject, AuthenticationCoordinator
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
// Listen to the end of the authentication flow
|
||||
// Listen to the end of the authentication flow.
|
||||
authenticationViewController.authVCDelegate = self
|
||||
// Listen for changes from deep links.
|
||||
AuthenticationService.shared.delegate = self
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
@@ -97,10 +99,6 @@ final class LegacyAuthenticationCoordinator: NSObject, AuthenticationCoordinator
|
||||
authenticationViewController.showCustomHomeserver(homeserver, andIdentityServer: identityServer)
|
||||
}
|
||||
|
||||
func continueSSOLogin(withToken loginToken: String, transactionID: String) -> Bool {
|
||||
authenticationViewController.continueSSOLogin(withToken: loginToken, txnId: transactionID)
|
||||
}
|
||||
|
||||
func presentPendingScreensIfNecessary() {
|
||||
canPresentAdditionalScreens = true
|
||||
|
||||
@@ -147,6 +145,13 @@ final class LegacyAuthenticationCoordinator: NSObject, AuthenticationCoordinator
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AuthenticationServiceDelegate
|
||||
extension LegacyAuthenticationCoordinator: AuthenticationServiceDelegate {
|
||||
func authenticationService(_ service: AuthenticationService, didReceive ssoLoginToken: String, with transactionID: String) -> Bool {
|
||||
authenticationViewController.continueSSOLogin(withToken: ssoLoginToken, txnId: transactionID)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AuthenticationViewControllerDelegate
|
||||
extension LegacyAuthenticationCoordinator: AuthenticationViewControllerDelegate {
|
||||
func authenticationViewController(_ authenticationViewController: AuthenticationViewController,
|
||||
|
||||
@@ -72,17 +72,12 @@ final class SSOAuthenticationPresenter: NSObject {
|
||||
self.identityProvider = identityProvider
|
||||
self.presentingViewController = presentingViewController
|
||||
|
||||
// NOTE: By using SFAuthenticationSession the consent alert show product name instead of display name. Fallback to SFSafariViewController instead in order to not disturb users with "Riot" wording at the moment.
|
||||
// (https://stackoverflow.com/questions/49860338/why-does-sfauthenticationsession-consent-alert-show-xcode-project-name-instead-o)
|
||||
if #available(iOS 13.0, *) {
|
||||
if #unavailable(iOS 15.0), UIAccessibility.isGuidedAccessEnabled {
|
||||
// SFAuthenticationSession and ASWebAuthenticationSession doesn't work with guided access (rdar://48376122)
|
||||
if UIAccessibility.isGuidedAccessEnabled {
|
||||
self.presentSafariViewController(with: authenticationURL, animated: animated)
|
||||
} else {
|
||||
self.startAuthenticationSession(with: authenticationURL)
|
||||
}
|
||||
// Confirmed to be fixed on iOS 15, haven't been able to test on iOS 14.
|
||||
presentSafariViewController(with: authenticationURL, animated: animated)
|
||||
} else {
|
||||
self.presentSafariViewController(with: authenticationURL, animated: animated)
|
||||
startAuthenticationSession(with: authenticationURL)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,10 +42,22 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
|
||||
private let navigationRouter: NavigationRouterType
|
||||
private let authenticationService = AuthenticationService.shared
|
||||
|
||||
/// The initial screen to be shown when starting the coordinator.
|
||||
private let initialScreen: EntryPoint
|
||||
/// The type of authentication that was used to complete the flow.
|
||||
private var authenticationType: AuthenticationType?
|
||||
|
||||
/// The presenter used to handler authentication via SSO.
|
||||
private var ssoAuthenticationPresenter: SSOAuthenticationPresenter?
|
||||
/// The transaction ID used when presenting the SSO screen. Used when completing via a deep link.
|
||||
private var ssoTransactionID: String?
|
||||
|
||||
/// Whether the coordinator can present further screens after a successful login has occurred.
|
||||
private var canPresentAdditionalScreens: Bool
|
||||
/// `true` if presentation of the verification screen is blocked by `canPresentAdditionalScreens`.
|
||||
private var isWaitingToPresentCompleteSecurity = false
|
||||
|
||||
/// The listener object that informs the coordinator whether verification needs to be presented or not.
|
||||
private var verificationListener: SessionVerificationListener?
|
||||
|
||||
/// The password entered, for use when setting up cross-signing.
|
||||
@@ -72,9 +84,10 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
Task {
|
||||
Task { @MainActor in
|
||||
await startAuthenticationFlow()
|
||||
await MainActor.run { callback?(.didStart) }
|
||||
callback?(.didStart)
|
||||
authenticationService.delegate = self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,23 +110,33 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
|
||||
|
||||
/// Starts the authentication flow.
|
||||
@MainActor private func startAuthenticationFlow() async {
|
||||
do {
|
||||
let flow: AuthenticationFlow = initialScreen == .login ? .login : .register
|
||||
let homeserverAddress = authenticationService.state.homeserver.addressFromUser ?? authenticationService.state.homeserver.address
|
||||
try await authenticationService.startFlow(flow, for: homeserverAddress)
|
||||
} catch {
|
||||
MXLog.error("[AuthenticationCoordinator] start: Failed to start")
|
||||
displayError(message: error.localizedDescription)
|
||||
return
|
||||
let flow: AuthenticationFlow = initialScreen == .login ? .login : .register
|
||||
if initialScreen != .selectServerForRegistration {
|
||||
do {
|
||||
let homeserverAddress = authenticationService.state.homeserver.addressFromUser ?? authenticationService.state.homeserver.address
|
||||
try await authenticationService.startFlow(flow, for: homeserverAddress)
|
||||
} catch {
|
||||
MXLog.error("[AuthenticationCoordinator] start: Failed to start")
|
||||
displayError(message: error.localizedDescription)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
switch initialScreen {
|
||||
case .registration:
|
||||
showRegistrationScreen()
|
||||
if authenticationService.state.homeserver.needsRegistrationFallback {
|
||||
showFallback(for: flow)
|
||||
} else {
|
||||
showRegistrationScreen()
|
||||
}
|
||||
case .selectServerForRegistration:
|
||||
showServerSelectionScreen()
|
||||
case .login:
|
||||
showLoginScreen()
|
||||
if authenticationService.state.homeserver.needsLoginFallback {
|
||||
showFallback(for: flow)
|
||||
} else {
|
||||
showLoginScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,12 +167,56 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
|
||||
callback?(.cancel(.register))
|
||||
}
|
||||
|
||||
// MARK: - Login
|
||||
|
||||
/// Shows the login screen.
|
||||
@MainActor private func showLoginScreen() {
|
||||
MXLog.debug("[AuthenticationCoordinator] showLoginScreen")
|
||||
|
||||
let homeserver = authenticationService.state.homeserver
|
||||
let parameters = AuthenticationLoginCoordinatorParameters(navigationRouter: navigationRouter,
|
||||
authenticationService: authenticationService,
|
||||
loginMode: homeserver.preferredLoginMode)
|
||||
let coordinator = AuthenticationLoginCoordinator(parameters: parameters)
|
||||
coordinator.callback = { [weak self, weak coordinator] result in
|
||||
guard let self = self, let coordinator = coordinator else { return }
|
||||
self.loginCoordinator(coordinator, didCallbackWith: result)
|
||||
}
|
||||
|
||||
coordinator.start()
|
||||
add(childCoordinator: coordinator)
|
||||
|
||||
if navigationRouter.modules.isEmpty {
|
||||
navigationRouter.setRootModule(coordinator, popCompletion: nil)
|
||||
} else {
|
||||
navigationRouter.push(coordinator, animated: true) { [weak self] in
|
||||
self?.remove(childCoordinator: coordinator)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Displays the next view in the flow based on the result from the registration screen.
|
||||
@MainActor private func loginCoordinator(_ coordinator: AuthenticationLoginCoordinator,
|
||||
didCallbackWith result: AuthenticationLoginCoordinatorResult) {
|
||||
switch result {
|
||||
case .continueWithSSO(let provider):
|
||||
presentSSOAuthentication(for: provider)
|
||||
case .success(let session, let loginPassword):
|
||||
password = loginPassword
|
||||
authenticationType = .password
|
||||
onSessionCreated(session: session, flow: .login)
|
||||
case .fallback:
|
||||
showFallback(for: .login)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Registration
|
||||
|
||||
/// Pushes the server selection screen into the flow (other screens may also present it modally later).
|
||||
@MainActor private func showServerSelectionScreen() {
|
||||
MXLog.debug("[AuthenticationCoordinator] showServerSelectionScreen")
|
||||
let parameters = AuthenticationServerSelectionCoordinatorParameters(authenticationService: authenticationService,
|
||||
flow: .register,
|
||||
hasModalPresentation: false)
|
||||
let coordinator = AuthenticationServerSelectionCoordinator(parameters: parameters)
|
||||
coordinator.callback = { [weak self, weak coordinator] result in
|
||||
@@ -176,7 +243,11 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
|
||||
didCompleteWith result: AuthenticationServerSelectionCoordinatorResult) {
|
||||
switch result {
|
||||
case .updated:
|
||||
showRegistrationScreen()
|
||||
if authenticationService.state.homeserver.needsRegistrationFallback {
|
||||
showFallback(for: .register)
|
||||
} else {
|
||||
showRegistrationScreen()
|
||||
}
|
||||
case .dismiss:
|
||||
MXLog.failure("[AuthenticationCoordinator] AuthenticationServerSelectionScreen is requesting dismiss when part of a stack.")
|
||||
}
|
||||
@@ -193,7 +264,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
|
||||
let coordinator = AuthenticationRegistrationCoordinator(parameters: parameters)
|
||||
coordinator.callback = { [weak self, weak coordinator] result in
|
||||
guard let self = self, let coordinator = coordinator else { return }
|
||||
self.registrationCoordinator(coordinator, didCompleteWith: result)
|
||||
self.registrationCoordinator(coordinator, didCallbackWith: result)
|
||||
}
|
||||
|
||||
coordinator.start()
|
||||
@@ -208,12 +279,18 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
|
||||
}
|
||||
}
|
||||
|
||||
/// Displays the next view in the flow after the registration screen.
|
||||
/// Displays the next view in the flow based on the result from the registration screen.
|
||||
@MainActor private func registrationCoordinator(_ coordinator: AuthenticationRegistrationCoordinator,
|
||||
didCompleteWith result: AuthenticationRegistrationCoordinatorResult) {
|
||||
didCallbackWith result: AuthenticationRegistrationCoordinatorResult) {
|
||||
switch result {
|
||||
case .completed(let result):
|
||||
case .continueWithSSO(let provider):
|
||||
presentSSOAuthentication(for: provider)
|
||||
case .completed(let result, let registerPassword):
|
||||
password = registerPassword
|
||||
authenticationType = .password
|
||||
handleRegistrationResult(result)
|
||||
case .fallback:
|
||||
showFallback(for: .register)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,7 +320,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
|
||||
let localizedPolicies = terms?.policiesData(forLanguage: Bundle.mxk_language(), defaultLanguage: Bundle.mxk_fallbackLanguage())
|
||||
let parameters = AuthenticationTermsCoordinatorParameters(registrationWizard: registrationWizard,
|
||||
localizedPolicies: localizedPolicies ?? [],
|
||||
homeserverAddress: homeserver.addressFromUser ?? homeserver.address)
|
||||
homeserverAddress: homeserver.displayableAddress)
|
||||
let coordinator = AuthenticationTermsCoordinator(parameters: parameters)
|
||||
coordinator.callback = { [weak self] result in
|
||||
self?.registrationStageDidComplete(with: result)
|
||||
@@ -310,12 +387,6 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the login screen.
|
||||
@MainActor private func showLoginScreen() {
|
||||
MXLog.debug("[AuthenticationCoordinator] showLoginScreen")
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Registration Handlers
|
||||
/// Determines the next screen to show from the flow result and pushes it.
|
||||
@MainActor private func handleRegistrationResult(_ result: RegistrationResult) {
|
||||
@@ -354,15 +425,14 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
|
||||
case .dummy:
|
||||
MXLog.failure("[AuthenticationCoordinator] Attempting to perform the dummy stage.")
|
||||
case .other:
|
||||
#warning("Show fallback")
|
||||
MXLog.failure("[AuthenticationCoordinator] Attempting to perform an unsupported stage.")
|
||||
showFallback(for: .register)
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles the creation of a new session following on from a successful authentication.
|
||||
@MainActor private func onSessionCreated(session: MXSession, flow: AuthenticationFlow) {
|
||||
self.session = session
|
||||
// self.password = password
|
||||
|
||||
if canPresentAdditionalScreens {
|
||||
showLoadingAnimation()
|
||||
@@ -390,11 +460,38 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
|
||||
verificationListener.start()
|
||||
self.verificationListener = verificationListener
|
||||
|
||||
#warning("Add authentication type to the new flow")
|
||||
callback?(.didLogin(session: session, authenticationFlow: flow, authenticationType: .other))
|
||||
callback?(.didLogin(session: session, authenticationFlow: flow, authenticationType: authenticationType ?? .other))
|
||||
}
|
||||
|
||||
// MARK: - Additional Screens
|
||||
|
||||
private func showFallback(for flow: AuthenticationFlow) {
|
||||
let url = authenticationService.fallbackURL(for: flow)
|
||||
|
||||
MXLog.debug("[AuthenticationCoordinator] showFallback for: \(flow), url: \(url)")
|
||||
|
||||
guard let fallbackVC = AuthFallBackViewController(url: url.absoluteString) else {
|
||||
MXLog.error("[AuthenticationCoordinator] showFallback: could not create fallback view controller")
|
||||
return
|
||||
}
|
||||
fallbackVC.delegate = self
|
||||
let navController = RiotNavigationController(rootViewController: fallbackVC)
|
||||
navController.navigationBar.topItem?.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel,
|
||||
target: self,
|
||||
action: #selector(dismissFallback))
|
||||
navigationRouter.present(navController, animated: true)
|
||||
}
|
||||
|
||||
@objc
|
||||
private func dismissFallback() {
|
||||
MXLog.debug("[AuthenticationCoorrdinator] dismissFallback")
|
||||
|
||||
guard let fallbackNavigationVC = navigationRouter.toPresentable().presentedViewController as? RiotNavigationController else {
|
||||
return
|
||||
}
|
||||
fallbackNavigationVC.dismiss(animated: true)
|
||||
authenticationService.reset()
|
||||
}
|
||||
|
||||
/// Replace the contents of the navigation router with a loading animation.
|
||||
private func showLoadingAnimation() {
|
||||
@@ -409,7 +506,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
|
||||
/// Present the key verification screen modally.
|
||||
private func presentCompleteSecurity() {
|
||||
guard let session = session else {
|
||||
MXLog.error("[LegacyAuthenticationCoordinator] presentCompleteSecurity: Unable to present security due to missing session.")
|
||||
MXLog.error("[AuthenticationCoordinator] presentCompleteSecurity: Unable to present security due to missing session.")
|
||||
authenticationDidComplete()
|
||||
return
|
||||
}
|
||||
@@ -434,12 +531,96 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SSO
|
||||
|
||||
extension AuthenticationCoordinator: SSOAuthenticationPresenterDelegate {
|
||||
/// Presents SSO authentication for the specified identity provider.
|
||||
@MainActor private func presentSSOAuthentication(for identityProvider: SSOIdentityProvider) {
|
||||
let service = SSOAuthenticationService(homeserverStringURL: authenticationService.state.homeserver.address)
|
||||
let presenter = SSOAuthenticationPresenter(ssoAuthenticationService: service)
|
||||
presenter.delegate = self
|
||||
|
||||
let transactionID = MXTools.generateTransactionId()
|
||||
presenter.present(forIdentityProvider: identityProvider, with: transactionID, from: toPresentable(), animated: true)
|
||||
|
||||
ssoAuthenticationPresenter = presenter
|
||||
ssoTransactionID = transactionID
|
||||
authenticationType = .sso(identityProvider)
|
||||
}
|
||||
|
||||
func ssoAuthenticationPresenter(_ presenter: SSOAuthenticationPresenter, authenticationSucceededWithToken token: String, usingIdentityProvider identityProvider: SSOIdentityProvider?) {
|
||||
MXLog.debug("[AuthenticationCoordinator] SSO authentication succeeded.")
|
||||
|
||||
guard let loginWizard = authenticationService.loginWizard else {
|
||||
MXLog.failure("[AuthenticationCoordinator] The login wizard was requested before getting the login flow.")
|
||||
return
|
||||
}
|
||||
|
||||
Task { await handleLoginToken(token, using: loginWizard) }
|
||||
}
|
||||
|
||||
func ssoAuthenticationPresenter(_ presenter: SSOAuthenticationPresenter, authenticationDidFailWithError error: Error) {
|
||||
MXLog.debug("[AuthenticationCoordinator] SSO authentication failed.")
|
||||
|
||||
Task { @MainActor in
|
||||
displayError(message: error.localizedDescription)
|
||||
ssoAuthenticationPresenter = nil
|
||||
ssoTransactionID = nil
|
||||
authenticationType = nil
|
||||
}
|
||||
}
|
||||
|
||||
func ssoAuthenticationPresenterDidCancel(_ presenter: SSOAuthenticationPresenter) {
|
||||
MXLog.debug("[AuthenticationCoordinator] SSO authentication cancelled.")
|
||||
ssoAuthenticationPresenter = nil
|
||||
ssoTransactionID = nil
|
||||
authenticationType = nil
|
||||
}
|
||||
|
||||
/// Performs the last step of the login process for a flow that authenticated via SSO.
|
||||
@MainActor private func handleLoginToken(_ token: String, using loginWizard: LoginWizard) async {
|
||||
do {
|
||||
let session = try await loginWizard.login(with: token)
|
||||
onSessionCreated(session: session, flow: authenticationService.state.flow)
|
||||
} catch {
|
||||
MXLog.error("[AuthenticationCoordinator] Login with SSO token failed.")
|
||||
displayError(message: error.localizedDescription)
|
||||
authenticationType = nil
|
||||
}
|
||||
|
||||
ssoAuthenticationPresenter = nil
|
||||
ssoTransactionID = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AuthenticationServiceDelegate
|
||||
extension AuthenticationCoordinator: AuthenticationServiceDelegate {
|
||||
func authenticationService(_ service: AuthenticationService, didReceive ssoLoginToken: String, with transactionID: String) -> Bool {
|
||||
guard let presenter = ssoAuthenticationPresenter, transactionID == ssoTransactionID else {
|
||||
Task { await displayError(message: VectorL10n.errorCommonMessage) }
|
||||
return false
|
||||
}
|
||||
|
||||
guard let loginWizard = authenticationService.loginWizard else {
|
||||
MXLog.failure("[AuthenticationCoordinator] The login wizard was requested before getting the login flow.")
|
||||
return false
|
||||
}
|
||||
|
||||
Task {
|
||||
await handleLoginToken(ssoLoginToken, using: loginWizard)
|
||||
await MainActor.run { presenter.dismiss(animated: true, completion: nil) }
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - KeyVerificationCoordinatorDelegate
|
||||
extension AuthenticationCoordinator: KeyVerificationCoordinatorDelegate {
|
||||
func keyVerificationCoordinatorDidComplete(_ coordinator: KeyVerificationCoordinatorType, otherUserId: String, otherDeviceId: String) {
|
||||
if let crypto = session?.crypto,
|
||||
!crypto.backup.hasPrivateKeyInCryptoStore || !crypto.backup.enabled {
|
||||
MXLog.debug("[LegacyAuthenticationCoordinator][MXKeyVerification] requestAllPrivateKeys: Request key backup private keys")
|
||||
MXLog.debug("[AuthenticationCoordinator][MXKeyVerification] requestAllPrivateKeys: Request key backup private keys")
|
||||
crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil)
|
||||
}
|
||||
|
||||
@@ -487,9 +668,24 @@ extension AuthenticationCoordinator {
|
||||
func updateHomeserver(_ homeserver: String?, andIdentityServer identityServer: String?) {
|
||||
// unused
|
||||
}
|
||||
|
||||
func continueSSOLogin(withToken loginToken: String, transactionID: String) -> Bool {
|
||||
#warning("To be implemented elsewhere")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AuthFallBackViewControllerDelegate
|
||||
extension AuthenticationCoordinator: AuthFallBackViewControllerDelegate {
|
||||
func authFallBackViewController(_ authFallBackViewController: AuthFallBackViewController,
|
||||
didLoginWith loginResponse: MXLoginResponse) {
|
||||
let credentials = MXCredentials(loginResponse: loginResponse, andDefaultCredentials: nil)
|
||||
let client = MXRestClient(credentials: credentials)
|
||||
guard let session = MXSession(matrixRestClient: client) else {
|
||||
MXLog.failure("[AuthenticationCoordinator] authFallBackViewController:didLogin: session could not be created")
|
||||
return
|
||||
}
|
||||
authenticationType = .other
|
||||
Task { await onSessionCreated(session: session, flow: authenticationService.state.flow) }
|
||||
}
|
||||
|
||||
func authFallBackViewControllerDidClose(_ authFallBackViewController: AuthFallBackViewController) {
|
||||
dismissFallback()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -54,8 +54,10 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
|
||||
private var navigationRouter: NavigationRouterType {
|
||||
parameters.router
|
||||
}
|
||||
// Keep a strong ref as we need to init authVC early to preload its view
|
||||
private let authenticationCoordinator: AuthenticationCoordinatorProtocol
|
||||
/// A strong ref to the legacy authVC as we need to init early to preload its view.
|
||||
private let legacyAuthenticationCoordinator: LegacyAuthenticationCoordinator
|
||||
/// The currently active authentication coordinator, otherwise `nil`.
|
||||
private weak var authenticationCoordinator: AuthenticationCoordinatorProtocol?
|
||||
#warning("This might be removable when SSO comes through the AuthenticationService?")
|
||||
/// A boolean to prevent authentication being shown when already in progress.
|
||||
private var isShowingLegacyAuthentication = false
|
||||
@@ -90,9 +92,9 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
|
||||
init(parameters: OnboardingCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
// Preload the authVC (it is *really* slow to load in realtime)
|
||||
// Preload the legacy authVC (it is *really* slow to load in realtime)
|
||||
let authenticationParameters = LegacyAuthenticationCoordinatorParameters(navigationRouter: parameters.router, canPresentAdditionalScreens: false)
|
||||
authenticationCoordinator = LegacyAuthenticationCoordinator(parameters: authenticationParameters)
|
||||
legacyAuthenticationCoordinator = LegacyAuthenticationCoordinator(parameters: authenticationParameters)
|
||||
|
||||
super.init()
|
||||
}
|
||||
@@ -116,20 +118,14 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
|
||||
/// For more information see `AuthenticationViewController.externalRegistrationParameters`.
|
||||
func update(externalRegistrationParameters: [AnyHashable: Any]) {
|
||||
self.externalRegistrationParameters = externalRegistrationParameters
|
||||
authenticationCoordinator.update(externalRegistrationParameters: externalRegistrationParameters)
|
||||
legacyAuthenticationCoordinator.update(externalRegistrationParameters: externalRegistrationParameters)
|
||||
}
|
||||
|
||||
/// Set up the authentication screen with the specified homeserver and/or identity server.
|
||||
func updateHomeserver(_ homeserver: String?, andIdentityServer identityServer: String?) {
|
||||
self.customHomeserver = homeserver
|
||||
self.customIdentityServer = identityServer
|
||||
authenticationCoordinator.updateHomeserver(homeserver, andIdentityServer: identityServer)
|
||||
}
|
||||
|
||||
/// When SSO login succeeded, when SFSafariViewController is used, continue login with success parameters.
|
||||
func continueSSOLogin(withToken loginToken: String, transactionID: String) -> Bool {
|
||||
guard isShowingLegacyAuthentication else { return false }
|
||||
return authenticationCoordinator.continueSSOLogin(withToken: loginToken, transactionID: transactionID)
|
||||
legacyAuthenticationCoordinator.updateHomeserver(homeserver, andIdentityServer: identityServer)
|
||||
}
|
||||
|
||||
// MARK: - Pre-Authentication
|
||||
@@ -156,14 +152,19 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
|
||||
private func splashScreenCoordinator(_ coordinator: OnboardingSplashScreenCoordinator, didCompleteWith result: OnboardingSplashScreenViewModelResult) {
|
||||
splashScreenResult = result
|
||||
|
||||
// Set the auth type early to allow network requests to finish during display of the use case screen.
|
||||
authenticationCoordinator.update(authenticationFlow: result.flow)
|
||||
// Set the auth type early on the legacy auth to allow network requests to finish during display of the use case screen.
|
||||
legacyAuthenticationCoordinator.update(authenticationFlow: result.flow)
|
||||
|
||||
switch result {
|
||||
case .register:
|
||||
showUseCaseSelectionScreen()
|
||||
case .login:
|
||||
showLegacyAuthenticationScreen()
|
||||
if BuildSettings.onboardingEnableNewAuthenticationFlow {
|
||||
beginAuthentication(with: .login, onStart: coordinator.stop)
|
||||
} else {
|
||||
coordinator.stop()
|
||||
showLegacyAuthenticationScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,6 +233,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
|
||||
self.cancelAuthentication(flow: flow)
|
||||
}
|
||||
}
|
||||
authenticationCoordinator = coordinator
|
||||
|
||||
add(childCoordinator: coordinator)
|
||||
coordinator.start()
|
||||
@@ -243,7 +245,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
|
||||
|
||||
MXLog.debug("[OnboardingCoordinator] showLegacyAuthenticationScreen")
|
||||
|
||||
let coordinator = authenticationCoordinator
|
||||
let coordinator = legacyAuthenticationCoordinator
|
||||
coordinator.callback = { [weak self, weak coordinator] result in
|
||||
guard let self = self, let coordinator = coordinator else { return }
|
||||
|
||||
@@ -256,7 +258,6 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
|
||||
// These results are only sent by the new flow.
|
||||
break
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Due to needing to preload the authVC, this breaks the Coordinator init/start pattern.
|
||||
@@ -272,6 +273,8 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
|
||||
coordinator.update(softLogoutCredentials: softLogoutCredentials)
|
||||
}
|
||||
|
||||
authenticationCoordinator = coordinator
|
||||
|
||||
coordinator.start()
|
||||
add(childCoordinator: coordinator)
|
||||
|
||||
@@ -567,7 +570,12 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
|
||||
}
|
||||
|
||||
guard authenticationFinished else {
|
||||
MXLog.debug("[OnboardingCoordinator] Allowing LegacyAuthenticationCoordinator to display any remaining screens.")
|
||||
guard let authenticationCoordinator = authenticationCoordinator else {
|
||||
MXLog.failure("[OnboardingCoordinator] completeIfReady: authenticationCoordinator is missing.")
|
||||
return
|
||||
}
|
||||
|
||||
MXLog.debug("[OnboardingCoordinator] Allowing AuthenticationCoordinator to display any remaining screens.")
|
||||
authenticationCoordinator.presentPendingScreensIfNecessary()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -97,12 +97,6 @@ final class OnboardingCoordinatorBridgePresenter: NSObject {
|
||||
coordinator?.updateHomeserver(homeserver, andIdentityServer: identityServer)
|
||||
}
|
||||
|
||||
/// When SSO login succeeded, when SFSafariViewController is used, continue login with success parameters.
|
||||
func continueSSOLogin(withToken loginToken: String, transactionID: String) -> Bool {
|
||||
guard let coordinator = coordinator else { return false }
|
||||
return coordinator.continueSSOLogin(withToken: loginToken, transactionID: transactionID)
|
||||
}
|
||||
|
||||
func dismiss(animated: Bool, completion: (() -> Void)?) {
|
||||
guard let coordinator = self.coordinator else {
|
||||
return
|
||||
|
||||
@@ -27,7 +27,4 @@ protocol OnboardingCoordinatorProtocol: Coordinator, Presentable {
|
||||
|
||||
/// Set up the authentication screen with the specified homeserver and/or identity server.
|
||||
func updateHomeserver(_ homeserver: String?, andIdentityServer identityServer: String?)
|
||||
|
||||
/// When SSO login succeeded, when SFSafariViewController is used, continue login with success parameters.
|
||||
func continueSSOLogin(withToken loginToken: String, transactionID: String) -> Bool
|
||||
}
|
||||
|
||||
@@ -1903,6 +1903,11 @@
|
||||
return;
|
||||
}
|
||||
|
||||
__block UserIndicatorCancel cancelIndicator;
|
||||
NSTimer *indicatorTimer = [NSTimer scheduledTimerWithTimeInterval:0.5 repeats:NO block:^(NSTimer * _Nonnull timer) {
|
||||
cancelIndicator = [self.userIndicatorStore presentLoadingWithLabel:[VectorL10n homeSyncing] isInteractionBlocking:NO];
|
||||
}];
|
||||
|
||||
// Store the current height of the first bubble (if any)
|
||||
backPaginationSavedFirstBubbleHeight = 0;
|
||||
if (direction == MXTimelineDirectionBackwards && [roomDataSource tableView:_bubblesTableView numberOfRowsInSection:0])
|
||||
@@ -1987,6 +1992,12 @@
|
||||
{
|
||||
[self updateCurrentEventIdAtTableBottom:NO];
|
||||
}
|
||||
|
||||
[indicatorTimer invalidate];
|
||||
|
||||
if (cancelIndicator) {
|
||||
cancelIndicator();
|
||||
}
|
||||
|
||||
} failure:^(NSError *error) {
|
||||
|
||||
@@ -2002,6 +2013,11 @@
|
||||
|
||||
self.bubbleTableViewDisplayInTransition = NO;
|
||||
|
||||
[indicatorTimer invalidate];
|
||||
|
||||
if (cancelIndicator) {
|
||||
cancelIndicator();
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
/// Information about a homeserver that is ready for display in the authentication flow.
|
||||
struct AuthenticationHomeserverViewData: Equatable {
|
||||
/// The homeserver string to be shown to the user.
|
||||
let address: String
|
||||
/// Whether or not the homeserver is matrix.org.
|
||||
let isMatrixDotOrg: Bool
|
||||
/// Whether or not to display the username and password text fields during login.
|
||||
let showLoginForm: Bool
|
||||
/// Whether or not to display the username and password text fields during registration.
|
||||
let showRegistrationForm: Bool
|
||||
/// The supported SSO login options.
|
||||
let ssoIdentityProviders: [SSOIdentityProvider]
|
||||
}
|
||||
|
||||
// MARK: - Mocks
|
||||
|
||||
extension AuthenticationHomeserverViewData {
|
||||
/// A mock homeserver that is configured just like matrix.org.
|
||||
static var mockMatrixDotOrg: AuthenticationHomeserverViewData {
|
||||
AuthenticationHomeserverViewData(address: "matrix.org",
|
||||
isMatrixDotOrg: true,
|
||||
showLoginForm: true,
|
||||
showRegistrationForm: true,
|
||||
ssoIdentityProviders: [
|
||||
SSOIdentityProvider(id: "1", name: "Apple", brand: "apple", iconURL: nil),
|
||||
SSOIdentityProvider(id: "2", name: "Facebook", brand: "facebook", iconURL: nil),
|
||||
SSOIdentityProvider(id: "3", name: "GitHub", brand: "github", iconURL: nil),
|
||||
SSOIdentityProvider(id: "4", name: "GitLab", brand: "gitlab", iconURL: nil),
|
||||
SSOIdentityProvider(id: "5", name: "Google", brand: "google", iconURL: nil)
|
||||
])
|
||||
}
|
||||
|
||||
/// A mock homeserver that supports login and registration via a password but has no SSO providers.
|
||||
static var mockBasicServer: AuthenticationHomeserverViewData {
|
||||
AuthenticationHomeserverViewData(address: "example.com",
|
||||
isMatrixDotOrg: false,
|
||||
showLoginForm: true,
|
||||
showRegistrationForm: true,
|
||||
ssoIdentityProviders: [])
|
||||
}
|
||||
|
||||
/// A mock homeserver that supports only supports authentication via a single SSO provider.
|
||||
static var mockEnterpriseSSO: AuthenticationHomeserverViewData {
|
||||
AuthenticationHomeserverViewData(address: "company.com",
|
||||
isMatrixDotOrg: false,
|
||||
showLoginForm: false,
|
||||
showRegistrationForm: false,
|
||||
ssoIdentityProviders: [SSOIdentityProvider(id: "test", name: "SAML", brand: nil, iconURL: nil)])
|
||||
}
|
||||
|
||||
/// A mock homeserver that supports only supports authentication via fallback.
|
||||
static var mockFallback: AuthenticationHomeserverViewData {
|
||||
AuthenticationHomeserverViewData(address: "company.com",
|
||||
isMatrixDotOrg: false,
|
||||
showLoginForm: false,
|
||||
showRegistrationForm: false,
|
||||
ssoIdentityProviders: [])
|
||||
}
|
||||
|
||||
}
|
||||
@@ -78,6 +78,13 @@ enum LoginError: String, Error {
|
||||
case resetPasswordNotStarted
|
||||
}
|
||||
|
||||
struct HomeserverAddress {
|
||||
/// Ensures the address contains a scheme, otherwise makes it `https`.
|
||||
static func sanitized(_ address: String) -> String {
|
||||
!address.contains("://") ? "https://\(address.lowercased())" : address.lowercased()
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an SSO Identity Provider as provided in a login flow.
|
||||
@objc class SSOIdentityProvider: NSObject, Identifiable {
|
||||
/// The id field is the Identity Provider identifier used for the SSO Web page redirection `/login/sso/redirect/{idp_id}`.
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// An button that displays the icon and name of an SSO provider.
|
||||
/// A button that displays the icon and name of an SSO provider.
|
||||
struct AuthenticationSSOButton: View {
|
||||
|
||||
// MARK: - Constants
|
||||
@@ -28,6 +28,11 @@ struct AuthenticationSSOButton: View {
|
||||
// MARK: - Private
|
||||
|
||||
@Environment(\.theme) private var theme
|
||||
@ScaledMetric private var iconSize = 24
|
||||
|
||||
private var renderingMode: Image.TemplateRenderingMode? {
|
||||
provider.brand == Brand.apple.rawValue || provider.brand == Brand.github.rawValue ? .template : nil
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
@@ -52,32 +57,63 @@ struct AuthenticationSSOButton: View {
|
||||
.opacity(0)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(SecondaryActionButtonStyle(customColor: theme.colors.quinaryContent))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
/// The icon with appropriate rendering mode and size for dynamic type.
|
||||
var icon: some View {
|
||||
iconImage.map { image in
|
||||
image
|
||||
.renderingMode(renderingMode)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: iconSize, height: iconSize)
|
||||
.foregroundColor(renderingMode == .template ? theme.colors.primaryContent : nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// The image to be shown in the icon.
|
||||
var iconImage: Image? {
|
||||
switch provider.brand {
|
||||
case Brand.apple.rawValue:
|
||||
Image(Asset.Images.authenticationSsoIconApple.name)
|
||||
.renderingMode(.template)
|
||||
.foregroundColor(theme.colors.primaryContent)
|
||||
return Image(Asset.Images.authenticationSsoIconApple.name)
|
||||
case Brand.facebook.rawValue:
|
||||
Image(Asset.Images.authenticationSsoIconFacebook.name)
|
||||
return Image(Asset.Images.authenticationSsoIconFacebook.name)
|
||||
case Brand.github.rawValue:
|
||||
Image(Asset.Images.authenticationSsoIconGithub.name)
|
||||
.renderingMode(.template)
|
||||
.foregroundColor(theme.colors.primaryContent)
|
||||
return Image(Asset.Images.authenticationSsoIconGithub.name)
|
||||
case Brand.gitlab.rawValue:
|
||||
Image(Asset.Images.authenticationSsoIconGitlab.name)
|
||||
return Image(Asset.Images.authenticationSsoIconGitlab.name)
|
||||
case Brand.google.rawValue:
|
||||
Image(Asset.Images.authenticationSsoIconGoogle.name)
|
||||
return Image(Asset.Images.authenticationSsoIconGoogle.name)
|
||||
case Brand.twitter.rawValue:
|
||||
Image(Asset.Images.authenticationSsoIconTwitter.name)
|
||||
return Image(Asset.Images.authenticationSsoIconTwitter.name)
|
||||
default:
|
||||
EmptyView()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AuthenticationSSOButton_Previews: PreviewProvider {
|
||||
static var matrixDotOrg = AuthenticationHomeserverViewData.mockMatrixDotOrg
|
||||
|
||||
static var buttons: some View {
|
||||
VStack {
|
||||
ForEach(matrixDotOrg.ssoIdentityProviders) { provider in
|
||||
AuthenticationSSOButton(provider: provider) { }
|
||||
}
|
||||
AuthenticationSSOButton(provider: SSOIdentityProvider(id: "", name: "SAML", brand: nil, iconURL: nil)) { }
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
static var previews: some View {
|
||||
buttons
|
||||
.theme(.light).preferredColorScheme(.light)
|
||||
.environment(\.sizeCategory, .accessibilityLarge)
|
||||
buttons
|
||||
.theme(.dark).preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//
|
||||
// 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 SwiftUI
|
||||
|
||||
/// A view that shows information about the chosen homeserver,
|
||||
/// along with an edit button to pick a different one.
|
||||
struct AuthenticationServerInfoSection: View {
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
@Environment(\.theme) private var theme
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
let address: String
|
||||
let showMatrixDotOrgInfo: Bool
|
||||
let editAction: () -> Void
|
||||
|
||||
// MARK: - Views
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(VectorL10n.authenticationServerInfoTitle)
|
||||
.font(theme.fonts.subheadline)
|
||||
.foregroundColor(theme.colors.secondaryContent)
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(address)
|
||||
.font(theme.fonts.body)
|
||||
.foregroundColor(theme.colors.primaryContent)
|
||||
|
||||
if showMatrixDotOrgInfo {
|
||||
Text(VectorL10n.authenticationServerInfoMatrixDescription)
|
||||
.font(theme.fonts.caption1)
|
||||
.foregroundColor(theme.colors.tertiaryContent)
|
||||
.accessibilityIdentifier("serverDescriptionText")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: editAction) {
|
||||
Text(VectorL10n.edit)
|
||||
.font(theme.fonts.body)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(theme.colors.accent))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,14 @@
|
||||
import Foundation
|
||||
|
||||
protocol AuthenticationServiceDelegate: AnyObject {
|
||||
func authenticationServiceDidUpdateRegistrationParameters(_ authenticationService: AuthenticationService)
|
||||
/// The authentication service received an SSO login token via a deep link.
|
||||
/// This only occurs when SSOAuthenticationPresenter uses an SFSafariViewController.
|
||||
/// - Parameters:
|
||||
/// - service: The authentication service.
|
||||
/// - ssoLoginToken: The login token provided when SSO succeeded.
|
||||
/// - transactionID: The transaction ID generated during SSO page presentation.
|
||||
/// - Returns: `true` if the SSO login can be continued.
|
||||
func authenticationService(_ service: AuthenticationService, didReceive ssoLoginToken: String, with transactionID: String) -> Bool
|
||||
}
|
||||
|
||||
class AuthenticationService: NSObject {
|
||||
@@ -43,6 +50,9 @@ class AuthenticationService: NSObject {
|
||||
/// The current registration wizard or `nil` if `startFlow` hasn't been called for `.registration`.
|
||||
private(set) var registrationWizard: RegistrationWizard?
|
||||
|
||||
/// The authentication service's delegate.
|
||||
weak var delegate: AuthenticationServiceDelegate?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
override init() {
|
||||
@@ -84,14 +94,7 @@ class AuthenticationService: NSObject {
|
||||
}
|
||||
|
||||
func startFlow(_ flow: AuthenticationFlow, for homeserverAddress: String) async throws {
|
||||
reset()
|
||||
|
||||
let loginFlows = try await loginFlow(for: homeserverAddress)
|
||||
|
||||
state.homeserver = .init(address: loginFlows.homeserverAddress,
|
||||
addressFromUser: homeserverAddress,
|
||||
preferredLoginMode: loginFlows.loginMode,
|
||||
loginModeSupportedTypes: loginFlows.supportedLoginTypes)
|
||||
var (client, homeserver) = try await loginFlow(for: homeserverAddress)
|
||||
|
||||
let loginWizard = LoginWizard(client: client)
|
||||
self.loginWizard = loginWizard
|
||||
@@ -99,22 +102,20 @@ class AuthenticationService: NSObject {
|
||||
if flow == .register {
|
||||
do {
|
||||
let registrationWizard = RegistrationWizard(client: client)
|
||||
state.homeserver.registrationFlow = try await registrationWizard.registrationFlow()
|
||||
homeserver.registrationFlow = try await registrationWizard.registrationFlow()
|
||||
self.registrationWizard = registrationWizard
|
||||
} catch {
|
||||
guard state.homeserver.preferredLoginMode.hasSSO, error as? RegistrationError == .registrationDisabled else {
|
||||
guard homeserver.preferredLoginMode.hasSSO, error as? RegistrationError == .registrationDisabled else {
|
||||
throw error
|
||||
}
|
||||
// Continue without throwing when registration is disabled but SSO is available.
|
||||
}
|
||||
}
|
||||
|
||||
state.flow = flow
|
||||
}
|
||||
|
||||
/// Get a SSO url
|
||||
func getSSOURL(redirectUrl: String, deviceId: String?, providerId: String?) -> String? {
|
||||
fatalError("Not implemented.")
|
||||
// The state and client are set after trying the registration flow to
|
||||
// ensure the existing state isn't wiped out when an error occurs.
|
||||
self.state = AuthenticationState(flow: flow, homeserver: homeserver)
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// Get the sign in or sign up fallback URL
|
||||
@@ -136,15 +137,19 @@ class AuthenticationService: NSObject {
|
||||
func reset() {
|
||||
loginWizard = nil
|
||||
registrationWizard = nil
|
||||
|
||||
|
||||
// The previously used homeserver is re-used as `startFlow` will be called again a replace it anyway.
|
||||
let address = state.homeserver.addressFromUser ?? state.homeserver.address
|
||||
self.state = AuthenticationState(flow: .login, homeserverAddress: address)
|
||||
}
|
||||
|
||||
/// Create a session after a SSO successful login
|
||||
func makeSessionFromSSO(credentials: MXCredentials) -> MXSession {
|
||||
sessionCreator.createSession(credentials: credentials, client: client)
|
||||
/// Continues an SSO flow when completion comes via a deep link.
|
||||
/// - Parameters:
|
||||
/// - token: The login token provided when SSO succeeded.
|
||||
/// - transactionID: The transaction ID generated during SSO page presentation.
|
||||
/// - Returns: `true` if the SSO login can be continued.
|
||||
func continueSSOLogin(with token: String, and transactionID: String) -> Bool {
|
||||
delegate?.authenticationService(self, didReceive: token, with: transactionID) ?? false
|
||||
}
|
||||
|
||||
// /// Perform a well-known request, using the domain from the matrixId
|
||||
@@ -170,10 +175,11 @@ class AuthenticationService: NSObject {
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Request the supported login flows for this homeserver.
|
||||
/// Query the supported login flows for the supplied homeserver.
|
||||
/// This is the first method to call to be able to get a wizard to login or to create an account
|
||||
/// - Parameter homeserverAddress: The homeserver string entered by the user.
|
||||
private func loginFlow(for homeserverAddress: String) async throws -> LoginFlowResult {
|
||||
/// - Returns: A tuple containing the REST client for the server along with the homeserver state containing the login flows.
|
||||
private func loginFlow(for homeserverAddress: String) async throws -> (AuthenticationRestClient, AuthenticationState.Homeserver) {
|
||||
let homeserverAddress = HomeserverAddress.sanitized(homeserverAddress)
|
||||
|
||||
guard var homeserverURL = URL(string: homeserverAddress) else {
|
||||
@@ -181,8 +187,6 @@ class AuthenticationService: NSObject {
|
||||
throw AuthenticationError.invalidHomeserver
|
||||
}
|
||||
|
||||
let state = AuthenticationState(flow: .login, homeserverAddress: homeserverAddress)
|
||||
|
||||
if let wellKnown = try? await wellKnown(for: homeserverURL),
|
||||
let baseURL = URL(string: wellKnown.homeServer.baseUrl) {
|
||||
homeserverURL = baseURL
|
||||
@@ -193,28 +197,26 @@ class AuthenticationService: NSObject {
|
||||
|
||||
let loginFlow = try await getLoginFlowResult(client: client)
|
||||
|
||||
self.client = client
|
||||
self.state = state
|
||||
|
||||
return loginFlow
|
||||
let homeserver = AuthenticationState.Homeserver(address: loginFlow.homeserverAddress,
|
||||
addressFromUser: homeserverAddress,
|
||||
preferredLoginMode: loginFlow.loginMode)
|
||||
return (client, homeserver)
|
||||
}
|
||||
|
||||
/// Request the supported login flows for the corresponding session.
|
||||
/// This method is used to get the flows for a server after a soft-logout.
|
||||
/// - Parameter session: The MXSession where a soft-logout has occurred.
|
||||
private func loginFlow(for session: MXSession) async throws -> LoginFlowResult {
|
||||
private func loginFlow(for session: MXSession) async throws -> (AuthenticationRestClient, AuthenticationState.Homeserver) {
|
||||
guard let client = session.matrixRestClient else {
|
||||
MXLog.error("[AuthenticationService] loginFlow called on a session that doesn't have a matrixRestClient.")
|
||||
throw AuthenticationError.missingMXRestClient
|
||||
}
|
||||
let state = AuthenticationState(flow: .login, homeserverAddress: client.homeserver)
|
||||
|
||||
let loginFlow = try await getLoginFlowResult(client: session.matrixRestClient)
|
||||
|
||||
self.client = client
|
||||
self.state = state
|
||||
|
||||
return loginFlow
|
||||
let homeserver = AuthenticationState.Homeserver(address: loginFlow.homeserverAddress,
|
||||
preferredLoginMode: loginFlow.loginMode)
|
||||
return (client, homeserver)
|
||||
}
|
||||
|
||||
private func getLoginFlowResult(client: MXRestClient) async throws -> LoginFlowResult {
|
||||
|
||||
@@ -30,16 +30,24 @@ struct AuthenticationState {
|
||||
self.homeserver = Homeserver(address: homeserverAddress)
|
||||
}
|
||||
|
||||
init(flow: AuthenticationFlow, homeserver: Homeserver) {
|
||||
self.flow = flow
|
||||
self.homeserver = homeserver
|
||||
}
|
||||
|
||||
struct Homeserver {
|
||||
/// The homeserver address as returned by the server.
|
||||
var address: String
|
||||
/// The homeserver address as input by the user (it can differ to the well-known request).
|
||||
var addressFromUser: String?
|
||||
/// The homeserver's address formatted to be displayed to the user in labels, text fields etc.
|
||||
var displayableAddress: String {
|
||||
let address = addressFromUser ?? address
|
||||
return address.replacingOccurrences(of: "https://", with: "") // Only remove https. Leave http to indicate the server doesn't use SSL.
|
||||
}
|
||||
|
||||
/// The preferred login mode for the server
|
||||
var preferredLoginMode: LoginMode = .unknown
|
||||
/// Supported types for the login.
|
||||
var loginModeSupportedTypes = [MXLoginFlow]()
|
||||
|
||||
/// The response returned when querying the homeserver for registration flows.
|
||||
var registrationFlow: RegistrationResult?
|
||||
@@ -49,5 +57,32 @@ struct AuthenticationState {
|
||||
guard let url = URL(string: address) else { return false }
|
||||
return url.host == "matrix.org" || url.host == "matrix-client.matrix.org"
|
||||
}
|
||||
|
||||
/// The homeserver mapped into view data that is ready for display.
|
||||
var viewData: AuthenticationHomeserverViewData {
|
||||
AuthenticationHomeserverViewData(address: displayableAddress,
|
||||
isMatrixDotOrg: isMatrixDotOrg,
|
||||
showLoginForm: preferredLoginMode.supportsPasswordFlow,
|
||||
showRegistrationForm: registrationFlow != nil && !needsRegistrationFallback,
|
||||
ssoIdentityProviders: preferredLoginMode.ssoIdentityProviders ?? [])
|
||||
}
|
||||
|
||||
/// Needs authentication fallback for login
|
||||
var needsLoginFallback: Bool {
|
||||
return preferredLoginMode.isUnsupported
|
||||
}
|
||||
|
||||
/// Needs authentication fallback for registration
|
||||
var needsRegistrationFallback: Bool {
|
||||
guard let flow = registrationFlow else {
|
||||
return false
|
||||
}
|
||||
switch flow {
|
||||
case .flowResponse(let result):
|
||||
return result.needsFallback
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,15 @@ enum LoginMode {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var isUnsupported: Bool {
|
||||
switch self {
|
||||
case .unsupported:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Data obtained when calling `LoginWizard.resetPassword` that will be used
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//
|
||||
// Copyright 2021 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
|
||||
|
||||
// MARK: View model
|
||||
|
||||
enum AuthenticationLoginViewModelResult {
|
||||
/// The user would like to select another server.
|
||||
case selectServer
|
||||
/// Parse the username and update the homeserver if included.
|
||||
case parseUsername(String)
|
||||
/// The user would like to reset their password.
|
||||
case forgotPassword
|
||||
/// Login using the supplied credentials.
|
||||
case login(username: String, password: String)
|
||||
/// Continue using the supplied SSO provider.
|
||||
case continueWithSSO(SSOIdentityProvider)
|
||||
/// Continue using the fallback page
|
||||
case fallback
|
||||
}
|
||||
|
||||
// MARK: View
|
||||
|
||||
struct AuthenticationLoginViewState: BindableState {
|
||||
/// Data about the selected homeserver.
|
||||
var homeserver: AuthenticationHomeserverViewData
|
||||
/// Whether a new homeserver is currently being loaded.
|
||||
var isLoading: Bool = false
|
||||
/// View state that can be bound to from SwiftUI.
|
||||
var bindings: AuthenticationLoginBindings
|
||||
|
||||
/// Whether to show any SSO buttons.
|
||||
var showSSOButtons: Bool {
|
||||
!homeserver.ssoIdentityProviders.isEmpty
|
||||
}
|
||||
|
||||
/// `true` if it is possible to continue, otherwise `false`.
|
||||
var hasValidCredentials: Bool {
|
||||
!bindings.username.isEmpty && !bindings.password.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
struct AuthenticationLoginBindings {
|
||||
/// The username input by the user.
|
||||
var username = ""
|
||||
/// The password input by the user.
|
||||
var password = ""
|
||||
/// Information describing the currently displayed alert.
|
||||
var alertInfo: AlertInfo<AuthenticationLoginErrorType>?
|
||||
}
|
||||
|
||||
enum AuthenticationLoginViewAction {
|
||||
/// The user would like to select another server.
|
||||
case selectServer
|
||||
/// Parse the username to detect if a homeserver is included.
|
||||
case parseUsername
|
||||
/// The user would like to reset their password.
|
||||
case forgotPassword
|
||||
/// Continue using the input username and password.
|
||||
case next
|
||||
/// Continue using the fallback page
|
||||
case fallback
|
||||
/// Continue using the supplied SSO provider.
|
||||
case continueWithSSO(SSOIdentityProvider)
|
||||
}
|
||||
|
||||
enum AuthenticationLoginErrorType: Hashable {
|
||||
/// An error response from the homeserver.
|
||||
case mxError(String)
|
||||
/// The current homeserver address isn't valid.
|
||||
case invalidHomeserver
|
||||
/// The response from the homeserver was unexpected.
|
||||
case unknown
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//
|
||||
// Copyright 2021 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 SwiftUI
|
||||
|
||||
typealias AuthenticationLoginViewModelType = StateStoreViewModel<AuthenticationLoginViewState,
|
||||
Never,
|
||||
AuthenticationLoginViewAction>
|
||||
|
||||
class AuthenticationLoginViewModel: AuthenticationLoginViewModelType, AuthenticationLoginViewModelProtocol {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var callback: (@MainActor (AuthenticationLoginViewModelResult) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(homeserver: AuthenticationHomeserverViewData) {
|
||||
let bindings = AuthenticationLoginBindings()
|
||||
let viewState = AuthenticationLoginViewState(homeserver: homeserver, bindings: bindings)
|
||||
|
||||
super.init(initialViewState: viewState)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: AuthenticationLoginViewAction) {
|
||||
switch viewAction {
|
||||
case .selectServer:
|
||||
Task { await callback?(.selectServer) }
|
||||
case .parseUsername:
|
||||
Task { await callback?(.parseUsername(state.bindings.username)) }
|
||||
case .forgotPassword:
|
||||
Task { await callback?(.forgotPassword) }
|
||||
case .next:
|
||||
Task { await callback?(.login(username: state.bindings.username, password: state.bindings.password)) }
|
||||
case .fallback:
|
||||
Task { await callback?(.fallback) }
|
||||
case .continueWithSSO(let provider):
|
||||
Task { await callback?(.continueWithSSO(provider))}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor func update(isLoading: Bool) {
|
||||
guard state.isLoading != isLoading else { return }
|
||||
state.isLoading = isLoading
|
||||
}
|
||||
|
||||
@MainActor func update(homeserver: AuthenticationHomeserverViewData) {
|
||||
state.homeserver = homeserver
|
||||
}
|
||||
|
||||
@MainActor func displayError(_ type: AuthenticationLoginErrorType) {
|
||||
switch type {
|
||||
case .mxError(let message):
|
||||
state.bindings.alertInfo = AlertInfo(id: type,
|
||||
title: VectorL10n.error,
|
||||
message: message)
|
||||
case .invalidHomeserver:
|
||||
state.bindings.alertInfo = AlertInfo(id: type,
|
||||
title: VectorL10n.error,
|
||||
message: VectorL10n.authenticationServerSelectionGenericError)
|
||||
case .unknown:
|
||||
state.bindings.alertInfo = AlertInfo(id: type)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//
|
||||
// Copyright 2021 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
|
||||
|
||||
protocol AuthenticationLoginViewModelProtocol {
|
||||
|
||||
var callback: (@MainActor (AuthenticationLoginViewModelResult) -> Void)? { get set }
|
||||
var context: AuthenticationLoginViewModelType.Context { get }
|
||||
|
||||
/// Update the view to reflect that a new homeserver is being loaded.
|
||||
/// - Parameter isLoading: Whether or not the homeserver is being loaded.
|
||||
@MainActor func update(isLoading: Bool)
|
||||
|
||||
/// Update the view with new homeserver information.
|
||||
/// - Parameter homeserver: The view data for the homeserver. This can be generated using `AuthenticationService.Homeserver.viewData`.
|
||||
@MainActor func update(homeserver: AuthenticationHomeserverViewData)
|
||||
|
||||
/// Display an error to the user.
|
||||
/// - Parameter type: The type of error to be displayed.
|
||||
@MainActor func displayError(_ type: AuthenticationLoginErrorType)
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//
|
||||
// Copyright 2021 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 SwiftUI
|
||||
import CommonKit
|
||||
import MatrixSDK
|
||||
|
||||
struct AuthenticationLoginCoordinatorParameters {
|
||||
let navigationRouter: NavigationRouterType
|
||||
let authenticationService: AuthenticationService
|
||||
/// The login mode to allow SSO buttons to be shown when available.
|
||||
let loginMode: LoginMode
|
||||
}
|
||||
|
||||
enum AuthenticationLoginCoordinatorResult {
|
||||
/// Continue using the supplied SSO provider.
|
||||
case continueWithSSO(SSOIdentityProvider)
|
||||
/// Login was successful with the associated session created.
|
||||
case success(session: MXSession, password: String)
|
||||
/// Login requested a fallback
|
||||
case fallback
|
||||
}
|
||||
|
||||
final class AuthenticationLoginCoordinator: Coordinator, Presentable {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private let parameters: AuthenticationLoginCoordinatorParameters
|
||||
private let authenticationLoginHostingController: VectorHostingController
|
||||
private var authenticationLoginViewModel: AuthenticationLoginViewModelProtocol
|
||||
|
||||
private var currentTask: Task<Void, Error>? {
|
||||
willSet {
|
||||
currentTask?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private var navigationRouter: NavigationRouterType { parameters.navigationRouter }
|
||||
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
|
||||
private var waitingIndicator: UserIndicator?
|
||||
|
||||
/// The authentication service used for the login.
|
||||
private var authenticationService: AuthenticationService { parameters.authenticationService }
|
||||
/// The wizard used to handle the login flow. Will only be `nil` if there is a misconfiguration.
|
||||
private var loginWizard: LoginWizard? { parameters.authenticationService.loginWizard }
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
var callback: (@MainActor (AuthenticationLoginCoordinatorResult) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
@MainActor init(parameters: AuthenticationLoginCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
let homeserver = parameters.authenticationService.state.homeserver
|
||||
let viewModel = AuthenticationLoginViewModel(homeserver: homeserver.viewData)
|
||||
authenticationLoginViewModel = viewModel
|
||||
|
||||
let view = AuthenticationLoginScreen(viewModel: viewModel.context)
|
||||
authenticationLoginHostingController = VectorHostingController(rootView: view)
|
||||
authenticationLoginHostingController.vc_removeBackTitle()
|
||||
authenticationLoginHostingController.enableNavigationBarScrollEdgeAppearance = true
|
||||
|
||||
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: authenticationLoginHostingController)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
func start() {
|
||||
MXLog.debug("[AuthenticationLoginCoordinator] did start.")
|
||||
Task { await setupViewModel() }
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
authenticationLoginHostingController
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Set up the view model. This method is extracted from `start()` so it can run on the `MainActor`.
|
||||
@MainActor private func setupViewModel() {
|
||||
authenticationLoginViewModel.callback = { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
MXLog.debug("[AuthenticationLoginCoordinator] AuthenticationLoginViewModel did callback with result: \(result).")
|
||||
|
||||
switch result {
|
||||
case .selectServer:
|
||||
self.presentServerSelectionScreen()
|
||||
case .parseUsername(let username):
|
||||
self.parseUsername(username)
|
||||
case .forgotPassword:
|
||||
#warning("Show the forgot password flow.")
|
||||
case .login(let username, let password):
|
||||
self.login(username: username, password: password)
|
||||
case .continueWithSSO(let identityProvider):
|
||||
self.callback?(.continueWithSSO(identityProvider))
|
||||
case .fallback:
|
||||
self.callback?(.fallback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a blocking activity indicator whilst saving.
|
||||
@MainActor private func startLoading(isInteractionBlocking: Bool) {
|
||||
waitingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: isInteractionBlocking))
|
||||
|
||||
if !isInteractionBlocking {
|
||||
authenticationLoginViewModel.update(isLoading: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Hide the currently displayed activity indicator.
|
||||
@MainActor private func stopLoading() {
|
||||
authenticationLoginViewModel.update(isLoading: false)
|
||||
waitingIndicator = nil
|
||||
}
|
||||
|
||||
/// Login with the supplied username and password.
|
||||
@MainActor private func login(username: String, password: String) {
|
||||
guard let loginWizard = loginWizard else {
|
||||
MXLog.failure("[AuthenticationLoginCoordinator] The login wizard was requested before getting the login flow.")
|
||||
return
|
||||
}
|
||||
|
||||
startLoading(isInteractionBlocking: true)
|
||||
|
||||
currentTask = Task { [weak self] in
|
||||
do {
|
||||
let session = try await loginWizard.login(login: username,
|
||||
password: password,
|
||||
initialDeviceName: UIDevice.current.initialDisplayName)
|
||||
|
||||
guard !Task.isCancelled else { return }
|
||||
callback?(.success(session: session, password: password))
|
||||
|
||||
self?.stopLoading()
|
||||
} catch {
|
||||
self?.stopLoading()
|
||||
self?.handleError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Processes an error to either update the flow or display it to the user.
|
||||
@MainActor private func handleError(_ error: Error) {
|
||||
if let mxError = MXError(nsError: error as NSError) {
|
||||
authenticationLoginViewModel.displayError(.mxError(mxError.error))
|
||||
return
|
||||
}
|
||||
|
||||
if let authenticationError = error as? AuthenticationError {
|
||||
switch authenticationError {
|
||||
case .invalidHomeserver:
|
||||
authenticationLoginViewModel.displayError(.invalidHomeserver)
|
||||
case .loginFlowNotCalled:
|
||||
#warning("Reset the flow")
|
||||
case .missingMXRestClient:
|
||||
#warning("Forget the soft logout session")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
authenticationLoginViewModel.displayError(.unknown)
|
||||
}
|
||||
|
||||
@MainActor private func parseUsername(_ username: String) {
|
||||
guard MXTools.isMatrixUserIdentifier(username) else { return }
|
||||
let domain = username.split(separator: ":")[1]
|
||||
let homeserverAddress = HomeserverAddress.sanitized(String(domain))
|
||||
|
||||
startLoading(isInteractionBlocking: false)
|
||||
|
||||
currentTask = Task { [weak self] in
|
||||
do {
|
||||
try await authenticationService.startFlow(.login, for: homeserverAddress)
|
||||
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
updateViewModel()
|
||||
self?.stopLoading()
|
||||
} catch {
|
||||
self?.stopLoading()
|
||||
self?.handleError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Presents the server selection screen as a modal.
|
||||
@MainActor private func presentServerSelectionScreen() {
|
||||
MXLog.debug("[AuthenticationLoginCoordinator] presentServerSelectionScreen")
|
||||
let parameters = AuthenticationServerSelectionCoordinatorParameters(authenticationService: authenticationService,
|
||||
flow: .login,
|
||||
hasModalPresentation: true)
|
||||
let coordinator = AuthenticationServerSelectionCoordinator(parameters: parameters)
|
||||
coordinator.callback = { [weak self, weak coordinator] result in
|
||||
guard let self = self, let coordinator = coordinator else { return }
|
||||
self.serverSelectionCoordinator(coordinator, didCompleteWith: result)
|
||||
}
|
||||
|
||||
coordinator.start()
|
||||
add(childCoordinator: coordinator)
|
||||
|
||||
let modalRouter = NavigationRouter()
|
||||
modalRouter.setRootModule(coordinator)
|
||||
|
||||
navigationRouter.present(modalRouter, animated: true)
|
||||
}
|
||||
|
||||
/// Handles the result from the server selection modal, dismissing it after updating the view.
|
||||
@MainActor private func serverSelectionCoordinator(_ coordinator: AuthenticationServerSelectionCoordinator,
|
||||
didCompleteWith result: AuthenticationServerSelectionCoordinatorResult) {
|
||||
navigationRouter.dismissModule(animated: true) { [weak self] in
|
||||
if result == .updated {
|
||||
self?.updateViewModel()
|
||||
}
|
||||
|
||||
self?.remove(childCoordinator: coordinator)
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the view model to reflect any changes made to the homeserver.
|
||||
@MainActor private func updateViewModel() {
|
||||
let homeserver = authenticationService.state.homeserver
|
||||
authenticationLoginViewModel.update(homeserver: homeserver.viewData)
|
||||
|
||||
if homeserver.needsLoginFallback {
|
||||
callback?(.fallback)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//
|
||||
// Copyright 2021 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
|
||||
import SwiftUI
|
||||
|
||||
/// Using an enum for the screen allows you define the different state cases with
|
||||
/// the relevant associated data for each case.
|
||||
enum MockAuthenticationLoginScreenState: MockScreenState, CaseIterable {
|
||||
// A case for each state you want to represent
|
||||
// with specific, minimal associated data that will allow you
|
||||
// mock that screen.
|
||||
case matrixDotOrg
|
||||
case passwordOnly
|
||||
case passwordWithCredentials
|
||||
case ssoOnly
|
||||
case fallback
|
||||
|
||||
/// The associated screen
|
||||
var screenType: Any.Type {
|
||||
AuthenticationLoginScreen.self
|
||||
}
|
||||
|
||||
/// Generate the view struct for the screen state.
|
||||
var screenView: ([Any], AnyView) {
|
||||
let viewModel: AuthenticationLoginViewModel
|
||||
switch self {
|
||||
case .matrixDotOrg:
|
||||
viewModel = AuthenticationLoginViewModel(homeserver: .mockMatrixDotOrg)
|
||||
case .passwordOnly:
|
||||
viewModel = AuthenticationLoginViewModel(homeserver: .mockBasicServer)
|
||||
case .passwordWithCredentials:
|
||||
viewModel = AuthenticationLoginViewModel(homeserver: .mockBasicServer)
|
||||
viewModel.context.username = "alice"
|
||||
viewModel.context.password = "password"
|
||||
case .ssoOnly:
|
||||
viewModel = AuthenticationLoginViewModel(homeserver: .mockEnterpriseSSO)
|
||||
case .fallback:
|
||||
viewModel = AuthenticationLoginViewModel(homeserver: .mockFallback)
|
||||
}
|
||||
|
||||
|
||||
// can simulate service and viewModel actions here if needs be.
|
||||
|
||||
return (
|
||||
[viewModel], AnyView(AuthenticationLoginScreen(viewModel: viewModel.context))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//
|
||||
// Copyright 2021 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 XCTest
|
||||
import RiotSwiftUI
|
||||
|
||||
class AuthenticationLoginUITests: MockScreenTest {
|
||||
|
||||
override class var screenType: MockScreenState.Type {
|
||||
return MockAuthenticationLoginScreenState.self
|
||||
}
|
||||
|
||||
override class func createTest() -> MockScreenTest {
|
||||
return AuthenticationLoginUITests(selector: #selector(verifyAuthenticationLoginScreen))
|
||||
}
|
||||
|
||||
func verifyAuthenticationLoginScreen() throws {
|
||||
guard let screenState = screenState as? MockAuthenticationLoginScreenState else { fatalError("no screen") }
|
||||
switch screenState {
|
||||
case .matrixDotOrg:
|
||||
let state = "matrix.org"
|
||||
validateServerDescriptionIsVisible(for: state)
|
||||
validateLoginFormIsVisible(for: state)
|
||||
validateSSOButtonsAreShown(for: state)
|
||||
case .passwordOnly:
|
||||
let state = "a password only server"
|
||||
validateServerDescriptionIsHidden(for: state)
|
||||
validateLoginFormIsVisible(for: state)
|
||||
validateSSOButtonsAreHidden(for: state)
|
||||
|
||||
validateNextButtonIsDisabled(for: state)
|
||||
case .passwordWithCredentials:
|
||||
let state = "a password only server with credentials entered"
|
||||
validateNextButtonIsEnabled(for: state)
|
||||
case .ssoOnly:
|
||||
let state = "an SSO only server"
|
||||
validateServerDescriptionIsHidden(for: state)
|
||||
validateLoginFormIsHidden(for: state)
|
||||
validateSSOButtonsAreShown(for: state)
|
||||
case .fallback:
|
||||
let state = "a fallback server"
|
||||
validateFallback(for: state)
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks that the server description label is shown.
|
||||
func validateServerDescriptionIsVisible(for state: String) {
|
||||
let descriptionLabel = app.staticTexts["serverDescriptionText"]
|
||||
|
||||
XCTAssertTrue(descriptionLabel.exists, "The server description should be shown for \(state).")
|
||||
XCTAssertEqual(descriptionLabel.label, VectorL10n.authenticationServerInfoMatrixDescription, "The server description should be correct for \(state).")
|
||||
}
|
||||
|
||||
/// Checks that the server description label is hidden.
|
||||
func validateServerDescriptionIsHidden(for state: String) {
|
||||
let descriptionLabel = app.staticTexts["serverDescriptionText"]
|
||||
XCTAssertFalse(descriptionLabel.exists, "The server description should be shown for \(state).")
|
||||
}
|
||||
|
||||
/// Checks that the username and password text fields are shown along with the next button.
|
||||
func validateLoginFormIsVisible(for state: String) {
|
||||
let usernameTextField = app.textFields.element
|
||||
let passwordTextField = app.secureTextFields.element
|
||||
let nextButton = app.buttons["nextButton"]
|
||||
|
||||
XCTAssertTrue(usernameTextField.exists, "Username input should be shown for \(state).")
|
||||
XCTAssertTrue(passwordTextField.exists, "Password input should be shown for \(state).")
|
||||
XCTAssertTrue(nextButton.exists, "The next button should be shown for \(state).")
|
||||
}
|
||||
|
||||
/// Checks that the username and password text fields are hidden along with the next button.
|
||||
func validateLoginFormIsHidden(for state: String) {
|
||||
let usernameTextField = app.textFields.element
|
||||
let passwordTextField = app.secureTextFields.element
|
||||
let nextButton = app.buttons["nextButton"]
|
||||
|
||||
XCTAssertFalse(usernameTextField.exists, "Username input should not be shown for \(state).")
|
||||
XCTAssertFalse(passwordTextField.exists, "Password input should not be shown for \(state).")
|
||||
XCTAssertFalse(nextButton.exists, "The next button should not be shown for \(state).")
|
||||
}
|
||||
|
||||
/// Checks that there is at least one SSO button shown on the screen.
|
||||
func validateSSOButtonsAreShown(for state: String) {
|
||||
let ssoButtons = app.buttons.matching(identifier: "ssoButton")
|
||||
XCTAssertGreaterThan(ssoButtons.count, 0, "There should be at least 1 SSO button shown for \(state).")
|
||||
}
|
||||
|
||||
/// Checks that no SSO buttons shown on the screen.
|
||||
func validateSSOButtonsAreHidden(for state: String) {
|
||||
let ssoButtons = app.buttons.matching(identifier: "ssoButton")
|
||||
XCTAssertEqual(ssoButtons.count, 0, "There should not be any SSO buttons shown for \(state).")
|
||||
}
|
||||
|
||||
/// Checks that the next button is shown but is disabled.
|
||||
func validateNextButtonIsDisabled(for state: String) {
|
||||
let nextButton = app.buttons["nextButton"]
|
||||
XCTAssertTrue(nextButton.exists, "The next button should be shown.")
|
||||
XCTAssertFalse(nextButton.isEnabled, "The next button should be disabled for \(state).")
|
||||
}
|
||||
|
||||
/// Checks that the next button is shown and is enabled.
|
||||
func validateNextButtonIsEnabled(for state: String) {
|
||||
let nextButton = app.buttons["nextButton"]
|
||||
XCTAssertTrue(nextButton.exists, "The next button should be shown.")
|
||||
XCTAssertTrue(nextButton.isEnabled, "The next button should be enabled for \(state).")
|
||||
}
|
||||
|
||||
func validateFallback(for state: String) {
|
||||
let usernameTextField = app.textFields.element
|
||||
let passwordTextField = app.secureTextFields.element
|
||||
let nextButton = app.buttons["nextButton"]
|
||||
let ssoButtons = app.buttons.matching(identifier: "ssoButton")
|
||||
let fallbackButton = app.buttons["fallbackButton"]
|
||||
|
||||
XCTAssertFalse(usernameTextField.exists, "Username input should not be shown for \(state).")
|
||||
XCTAssertFalse(passwordTextField.exists, "Password input should not be shown for \(state).")
|
||||
XCTAssertFalse(nextButton.exists, "The next button should not be shown for \(state).")
|
||||
XCTAssertEqual(ssoButtons.count, 0, "There should not be any SSO buttons shown for \(state).")
|
||||
XCTAssertTrue(fallbackButton.exists, "The fallback button should be shown for \(state).")
|
||||
XCTAssertTrue(fallbackButton.isEnabled, "The fallback button should be enabled for \(state).")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//
|
||||
// Copyright 2021 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 XCTest
|
||||
|
||||
@testable import RiotSwiftUI
|
||||
|
||||
class AuthenticationLoginViewModelTests: XCTestCase {
|
||||
let defaultHomeserver = AuthenticationHomeserverViewData.mockMatrixDotOrg
|
||||
var viewModel: AuthenticationLoginViewModelProtocol!
|
||||
var context: AuthenticationLoginViewModelType.Context!
|
||||
|
||||
@MainActor override func setUp() async throws {
|
||||
viewModel = AuthenticationLoginViewModel(homeserver: defaultHomeserver)
|
||||
context = viewModel.context
|
||||
}
|
||||
|
||||
func testMatrixDotOrg() {
|
||||
// Given the initial view model configured for matrix.org with some SSO providers.
|
||||
let homeserver = defaultHomeserver
|
||||
|
||||
// Then the view state should contain a homeserver that matches matrix.org and shows SSO buttons.
|
||||
XCTAssertEqual(context.viewState.homeserver, homeserver, "The homeserver data should match the original.")
|
||||
XCTAssertTrue(context.viewState.showSSOButtons, "The SSO buttons should be shown.")
|
||||
}
|
||||
|
||||
@MainActor func testBasicServer() {
|
||||
// Given a basic server example.com that only supports password registration.
|
||||
let homeserver = AuthenticationHomeserverViewData.mockBasicServer
|
||||
|
||||
// When updating the view model with the server.
|
||||
viewModel.update(homeserver: homeserver)
|
||||
|
||||
// Then the view state should be updated with the homeserver and hide the SSO buttons.
|
||||
XCTAssertEqual(context.viewState.homeserver, homeserver, "The homeserver data should should match the new homeserver.")
|
||||
XCTAssertFalse(context.viewState.showSSOButtons, "The SSO buttons should not be shown.")
|
||||
}
|
||||
|
||||
func testUsernameWithEmptyPassword() {
|
||||
// Given a form with an empty username and password.
|
||||
XCTAssertTrue(context.password.isEmpty, "The initial value for the password should be empty.")
|
||||
XCTAssertTrue(context.username.isEmpty, "The initial value for the username should be empty.")
|
||||
XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.")
|
||||
|
||||
// When entering a username without a password.
|
||||
context.username = "bob"
|
||||
context.password = ""
|
||||
|
||||
// Then the credentials should be considered invalid.
|
||||
XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.")
|
||||
}
|
||||
|
||||
func testEmptyUsernameWithPassword() {
|
||||
// Given a form with an empty username and password.
|
||||
XCTAssertTrue(context.password.isEmpty, "The initial value for the password should be empty.")
|
||||
XCTAssertTrue(context.username.isEmpty, "The initial value for the username should be empty.")
|
||||
XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.")
|
||||
|
||||
// When entering a password without a username.
|
||||
context.username = ""
|
||||
context.password = "12345678"
|
||||
|
||||
// Then the credentials should be considered invalid.
|
||||
XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.")
|
||||
}
|
||||
|
||||
func testValidCredentials() {
|
||||
// Given a form with an empty username and password.
|
||||
XCTAssertTrue(context.password.isEmpty, "The initial value for the password should be empty.")
|
||||
XCTAssertTrue(context.username.isEmpty, "The initial value for the username should be empty.")
|
||||
XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.")
|
||||
|
||||
// When entering a username and an 8-character password.
|
||||
context.username = "bob"
|
||||
context.password = "12345678"
|
||||
|
||||
// Then the credentials should be considered valid.
|
||||
XCTAssertTrue(context.viewState.hasValidCredentials, "The credentials should be valid when the username and password are valid.")
|
||||
}
|
||||
|
||||
@MainActor func testLoadingServer() {
|
||||
// Given a form with valid credentials.
|
||||
context.username = "bob"
|
||||
context.password = "12345678"
|
||||
XCTAssertTrue(context.viewState.hasValidCredentials, "The credentials should be valid.")
|
||||
XCTAssertFalse(context.viewState.isLoading, "The view shouldn't start in a loading state.")
|
||||
|
||||
// When updating the view model whilst loading a homeserver.
|
||||
viewModel.update(isLoading: true)
|
||||
|
||||
// Then the view state should reflect that the homeserver is loading.
|
||||
XCTAssertTrue(context.viewState.isLoading, "The view should now be in a loading state.")
|
||||
|
||||
// When updating the view model after loading a homeserver.
|
||||
viewModel.update(isLoading: false)
|
||||
|
||||
// Then the view state should reflect that the homeserver is now loaded.
|
||||
XCTAssertFalse(context.viewState.isLoading, "The view should be back in a loaded state.")
|
||||
}
|
||||
|
||||
@MainActor func testFallbackServer() {
|
||||
// Given a basic server example.com that only supports password registration.
|
||||
let homeserver = AuthenticationHomeserverViewData.mockFallback
|
||||
|
||||
// When updating the view model with the server.
|
||||
viewModel.update(homeserver: homeserver)
|
||||
|
||||
// Then the view state should be updated with the homeserver and hide the SSO buttons and login form.
|
||||
XCTAssertFalse(context.viewState.showSSOButtons, "The SSO buttons should not be shown.")
|
||||
XCTAssertFalse(context.viewState.homeserver.showLoginForm, "The login form should not be shown.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//
|
||||
// Copyright 2021 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 SwiftUI
|
||||
|
||||
struct AuthenticationLoginScreen: View {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
@Environment(\.theme) private var theme: ThemeSwiftUI
|
||||
|
||||
/// A boolean that can be toggled to give focus to the password text field.
|
||||
/// This must be manually set back to `false` when the text field finishes editing.
|
||||
@State private var isPasswordFocused = false
|
||||
|
||||
// MARK: Public
|
||||
|
||||
@ObservedObject var viewModel: AuthenticationLoginViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
.padding(.top, OnboardingMetrics.topPaddingToNavigationBar)
|
||||
.padding(.bottom, 36)
|
||||
|
||||
serverInfo
|
||||
.padding(.leading, 12)
|
||||
|
||||
Rectangle()
|
||||
.fill(theme.colors.quinaryContent)
|
||||
.frame(height: 1)
|
||||
.padding(.vertical, 21)
|
||||
|
||||
if viewModel.viewState.homeserver.showLoginForm {
|
||||
loginForm
|
||||
}
|
||||
|
||||
if viewModel.viewState.homeserver.showLoginForm && viewModel.viewState.showSSOButtons {
|
||||
Text(VectorL10n.or)
|
||||
.foregroundColor(theme.colors.secondaryContent)
|
||||
.padding(.top, 16)
|
||||
}
|
||||
|
||||
if viewModel.viewState.showSSOButtons {
|
||||
ssoButtons
|
||||
.padding(.top, 16)
|
||||
}
|
||||
|
||||
if !viewModel.viewState.homeserver.showLoginForm && !viewModel.viewState.showSSOButtons {
|
||||
fallbackButton
|
||||
}
|
||||
|
||||
}
|
||||
.readableFrame()
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.background(theme.colors.background.ignoresSafeArea())
|
||||
.alert(item: $viewModel.alertInfo) { $0.alert }
|
||||
.accentColor(theme.colors.accent)
|
||||
}
|
||||
|
||||
/// The header containing a Welcome Back title.
|
||||
var header: some View {
|
||||
Text(VectorL10n.authenticationLoginTitle)
|
||||
.font(theme.fonts.title2B)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(theme.colors.primaryContent)
|
||||
}
|
||||
|
||||
/// The sever information section that includes a button to select a different server.
|
||||
var serverInfo: some View {
|
||||
AuthenticationServerInfoSection(address: viewModel.viewState.homeserver.address,
|
||||
showMatrixDotOrgInfo: viewModel.viewState.homeserver.isMatrixDotOrg) {
|
||||
viewModel.send(viewAction: .selectServer)
|
||||
}
|
||||
}
|
||||
|
||||
/// The form with text fields for username and password, along with a submit button.
|
||||
var loginForm: some View {
|
||||
VStack(spacing: 14) {
|
||||
RoundedBorderTextField(placeHolder: VectorL10n.authenticationLoginUsername,
|
||||
text: $viewModel.username,
|
||||
isFirstResponder: false,
|
||||
configuration: UIKitTextInputConfiguration(returnKeyType: .next,
|
||||
autocapitalizationType: .none,
|
||||
autocorrectionType: .no),
|
||||
onEditingChanged: usernameEditingChanged,
|
||||
onCommit: { isPasswordFocused = true })
|
||||
.accessibilityIdentifier("usernameTextField")
|
||||
|
||||
Spacer().frame(height: 20)
|
||||
|
||||
RoundedBorderTextField(placeHolder: VectorL10n.authPasswordPlaceholder,
|
||||
text: $viewModel.password,
|
||||
isFirstResponder: isPasswordFocused,
|
||||
configuration: UIKitTextInputConfiguration(returnKeyType: .done,
|
||||
isSecureTextEntry: true),
|
||||
onEditingChanged: passwordEditingChanged,
|
||||
onCommit: submit)
|
||||
.accessibilityIdentifier("passwordTextField")
|
||||
|
||||
Button { viewModel.send(viewAction: .forgotPassword) } label: {
|
||||
Text(VectorL10n.authenticationLoginForgotPassword)
|
||||
.font(theme.fonts.body)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
Button(action: submit) {
|
||||
Text(VectorL10n.next)
|
||||
}
|
||||
.buttonStyle(PrimaryActionButtonStyle())
|
||||
.disabled(!viewModel.viewState.hasValidCredentials || viewModel.viewState.isLoading)
|
||||
.accessibilityIdentifier("nextButton")
|
||||
}
|
||||
}
|
||||
|
||||
/// A list of SSO buttons that can be used for login.
|
||||
var ssoButtons: some View {
|
||||
VStack(spacing: 16) {
|
||||
ForEach(viewModel.viewState.homeserver.ssoIdentityProviders) { provider in
|
||||
AuthenticationSSOButton(provider: provider) {
|
||||
viewModel.send(viewAction: .continueWithSSO(provider))
|
||||
}
|
||||
.accessibilityIdentifier("ssoButton")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A fallback button that can be used for login.
|
||||
var fallbackButton: some View {
|
||||
Button(action: fallback) {
|
||||
Text(VectorL10n.login)
|
||||
}
|
||||
.buttonStyle(PrimaryActionButtonStyle())
|
||||
.accessibilityIdentifier("fallbackButton")
|
||||
}
|
||||
|
||||
/// Parses the username for a homeserver.
|
||||
func usernameEditingChanged(isEditing: Bool) {
|
||||
guard !isEditing, !viewModel.username.isEmpty else { return }
|
||||
|
||||
viewModel.send(viewAction: .parseUsername)
|
||||
}
|
||||
|
||||
/// Resets the password field focus.
|
||||
func passwordEditingChanged(isEditing: Bool) {
|
||||
guard !isEditing else { return }
|
||||
isPasswordFocused = false
|
||||
}
|
||||
|
||||
/// Sends the `next` view action so long as valid credentials have been input.
|
||||
func submit() {
|
||||
guard viewModel.viewState.hasValidCredentials else { return }
|
||||
viewModel.send(viewAction: .next)
|
||||
}
|
||||
|
||||
/// Sends the `fallback` view action.
|
||||
func fallback() {
|
||||
viewModel.send(viewAction: .fallback)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
struct AuthenticationLogin_Previews: PreviewProvider {
|
||||
static let stateRenderer = MockAuthenticationLoginScreenState.stateRenderer
|
||||
static var previews: some View {
|
||||
stateRenderer.screenGroup(addNavigation: true)
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
}
|
||||
@@ -25,17 +25,17 @@ enum AuthenticationRegistrationViewModelResult {
|
||||
case validateUsername(String)
|
||||
/// Create an account using the supplied credentials.
|
||||
case createAccount(username: String, password: String)
|
||||
/// Continue using the supplied SSO provider.
|
||||
case continueWithSSO(SSOIdentityProvider)
|
||||
/// Continue using a fallback
|
||||
case fallback
|
||||
}
|
||||
|
||||
// MARK: View
|
||||
|
||||
struct AuthenticationRegistrationViewState: BindableState {
|
||||
/// The address of the homeserver.
|
||||
var homeserverAddress: String
|
||||
/// Whether or not to show the username and password text fields with the next button
|
||||
var showRegistrationForm: Bool
|
||||
/// An array containing the available SSO options for login.
|
||||
var ssoIdentityProviders: [SSOIdentityProvider]
|
||||
/// Data about the selected homeserver.
|
||||
var homeserver: AuthenticationHomeserverViewData
|
||||
/// View state that can be bound to from SwiftUI.
|
||||
var bindings: AuthenticationRegistrationBindings
|
||||
/// Whether or not the username field has been edited yet.
|
||||
@@ -55,15 +55,9 @@ struct AuthenticationRegistrationViewState: BindableState {
|
||||
usernameErrorMessage ?? VectorL10n.authenticationRegistrationUsernameFooter
|
||||
}
|
||||
|
||||
/// A description that can be shown for the currently selected homeserver.
|
||||
var serverDescription: String? {
|
||||
guard homeserverAddress == "matrix.org" else { return nil }
|
||||
return VectorL10n.authenticationRegistrationMatrixDescription
|
||||
}
|
||||
|
||||
/// Whether to show any SSO buttons.
|
||||
var showSSOButtons: Bool {
|
||||
!ssoIdentityProviders.isEmpty
|
||||
!homeserver.ssoIdentityProviders.isEmpty
|
||||
}
|
||||
|
||||
/// Whether the current `username` is valid.
|
||||
@@ -102,8 +96,10 @@ enum AuthenticationRegistrationViewAction {
|
||||
case clearUsernameError
|
||||
/// Continue using the input username and password.
|
||||
case next
|
||||
/// Login using the supplied SSO provider ID.
|
||||
case continueWithSSO(id: String)
|
||||
/// Continue using the supplied SSO provider.
|
||||
case continueWithSSO(SSOIdentityProvider)
|
||||
/// Continue using the fallback page
|
||||
case fallback
|
||||
}
|
||||
|
||||
enum AuthenticationRegistrationErrorType: Hashable {
|
||||
|
||||
@@ -27,16 +27,13 @@ class AuthenticationRegistrationViewModel: AuthenticationRegistrationViewModelTy
|
||||
|
||||
// MARK: Public
|
||||
|
||||
@MainActor var callback: (@MainActor (AuthenticationRegistrationViewModelResult) -> Void)?
|
||||
var callback: (@MainActor (AuthenticationRegistrationViewModelResult) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(homeserverAddress: String, showRegistrationForm: Bool = true, ssoIdentityProviders: [SSOIdentityProvider]) {
|
||||
init(homeserver: AuthenticationHomeserverViewData) {
|
||||
let bindings = AuthenticationRegistrationBindings()
|
||||
let viewState = AuthenticationRegistrationViewState(homeserverAddress: HomeserverAddress.displayable(homeserverAddress),
|
||||
showRegistrationForm: showRegistrationForm,
|
||||
ssoIdentityProviders: ssoIdentityProviders,
|
||||
bindings: bindings)
|
||||
let viewState = AuthenticationRegistrationViewState(homeserver: homeserver, bindings: bindings)
|
||||
|
||||
super.init(initialViewState: viewState)
|
||||
}
|
||||
@@ -55,15 +52,15 @@ class AuthenticationRegistrationViewModel: AuthenticationRegistrationViewModelTy
|
||||
Task { await clearUsernameError() }
|
||||
case .next:
|
||||
Task { await callback?(.createAccount(username: state.bindings.username, password: state.bindings.password)) }
|
||||
case .continueWithSSO(let id):
|
||||
break
|
||||
case .continueWithSSO(let provider):
|
||||
Task { await callback?(.continueWithSSO(provider)) }
|
||||
case .fallback:
|
||||
Task { await callback?(.fallback) }
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor func update(homeserverAddress: String, showRegistrationForm: Bool, ssoIdentityProviders: [SSOIdentityProvider]) {
|
||||
state.homeserverAddress = HomeserverAddress.displayable(homeserverAddress)
|
||||
state.showRegistrationForm = showRegistrationForm
|
||||
state.ssoIdentityProviders = ssoIdentityProviders
|
||||
@MainActor func update(homeserver: AuthenticationHomeserverViewData) {
|
||||
state.homeserver = homeserver
|
||||
}
|
||||
|
||||
@MainActor func displayError(_ type: AuthenticationRegistrationErrorType) {
|
||||
|
||||
@@ -18,16 +18,14 @@ import Foundation
|
||||
|
||||
protocol AuthenticationRegistrationViewModelProtocol {
|
||||
|
||||
@MainActor var callback: (@MainActor (AuthenticationRegistrationViewModelResult) -> Void)? { get set }
|
||||
var callback: (@MainActor (AuthenticationRegistrationViewModelResult) -> Void)? { get set }
|
||||
var context: AuthenticationRegistrationViewModelType.Context { get }
|
||||
|
||||
/// Update the view with new homeserver information.
|
||||
/// - Parameters:
|
||||
/// - homeserverAddress: The homeserver string to be shown to the user.
|
||||
/// - showRegistrationForm: Whether or not to display the username and password text fields.
|
||||
/// - ssoIdentityProviders: The supported SSO login options.
|
||||
@MainActor func update(homeserverAddress: String, showRegistrationForm: Bool, ssoIdentityProviders: [SSOIdentityProvider])
|
||||
/// - Parameter homeserver: The view data for the homeserver. This can be generated using `AuthenticationService.Homeserver.viewData`.
|
||||
@MainActor func update(homeserver: AuthenticationHomeserverViewData)
|
||||
|
||||
/// Display an error to the user.
|
||||
/// - Parameter type: The type of error to be displayed.
|
||||
@MainActor func displayError(_ type: AuthenticationRegistrationErrorType)
|
||||
}
|
||||
|
||||
@@ -28,8 +28,12 @@ struct AuthenticationRegistrationCoordinatorParameters {
|
||||
}
|
||||
|
||||
enum AuthenticationRegistrationCoordinatorResult {
|
||||
/// Continue using the supplied SSO provider.
|
||||
case continueWithSSO(SSOIdentityProvider)
|
||||
/// The screen completed with the associated registration result.
|
||||
case completed(RegistrationResult)
|
||||
case completed(result: RegistrationResult, password: String)
|
||||
/// Continue using the fallback
|
||||
case fallback
|
||||
}
|
||||
|
||||
final class AuthenticationRegistrationCoordinator: Coordinator, Presentable {
|
||||
@@ -53,26 +57,23 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable {
|
||||
private var waitingIndicator: UserIndicator?
|
||||
|
||||
/// The authentication service used for the registration.
|
||||
var authenticationService: AuthenticationService { parameters.authenticationService }
|
||||
private var authenticationService: AuthenticationService { parameters.authenticationService }
|
||||
/// The wizard used to handle the registration flow. May be `nil` when only SSO is supported.
|
||||
var registrationWizard: RegistrationWizard?
|
||||
private var registrationWizard: RegistrationWizard? { parameters.authenticationService.registrationWizard }
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
@MainActor var callback: ((AuthenticationRegistrationCoordinatorResult) -> Void)?
|
||||
var callback: (@MainActor (AuthenticationRegistrationCoordinatorResult) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
@MainActor init(parameters: AuthenticationRegistrationCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
self.registrationWizard = parameters.authenticationService.registrationWizard
|
||||
|
||||
let homeserver = parameters.authenticationService.state.homeserver
|
||||
let viewModel = AuthenticationRegistrationViewModel(homeserverAddress: homeserver.addressFromUser ?? homeserver.address,
|
||||
showRegistrationForm: homeserver.registrationFlow != nil,
|
||||
ssoIdentityProviders: parameters.loginMode.ssoIdentityProviders ?? [])
|
||||
let viewModel = AuthenticationRegistrationViewModel(homeserver: homeserver.viewData)
|
||||
authenticationRegistrationViewModel = viewModel
|
||||
|
||||
let view = AuthenticationRegistrationScreen(viewModel: viewModel.context)
|
||||
@@ -100,6 +101,7 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable {
|
||||
authenticationRegistrationViewModel.callback = { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
MXLog.debug("[AuthenticationRegistrationCoordinator] AuthenticationRegistrationViewModel did complete with result: \(result).")
|
||||
|
||||
switch result {
|
||||
case .selectServer:
|
||||
self.presentServerSelectionScreen()
|
||||
@@ -107,13 +109,17 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable {
|
||||
self.validateUsername(username)
|
||||
case .createAccount(let username, let password):
|
||||
self.createAccount(username: username, password: password)
|
||||
case .continueWithSSO(let provider):
|
||||
self.callback?(.continueWithSSO(provider))
|
||||
case .fallback:
|
||||
self.callback?(.fallback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a blocking activity indicator whilst saving.
|
||||
@MainActor private func startLoading(label: String? = nil) {
|
||||
waitingIndicator = indicatorPresenter.present(.loading(label: label ?? VectorL10n.loading, isInteractionBlocking: true))
|
||||
@MainActor private func startLoading() {
|
||||
waitingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true))
|
||||
}
|
||||
|
||||
/// Hide the currently displayed activity indicator.
|
||||
@@ -149,17 +155,16 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable {
|
||||
return
|
||||
}
|
||||
|
||||
// reAuthHelper.data = state.password
|
||||
let deviceDisplayName = UIDevice.current.isPhone ? VectorL10n.loginMobileDevice : VectorL10n.loginTabletDevice
|
||||
|
||||
startLoading()
|
||||
|
||||
currentTask = Task { [weak self] in
|
||||
do {
|
||||
let result = try await registrationWizard.createAccount(username: username, password: password, initialDeviceDisplayName: deviceDisplayName)
|
||||
let result = try await registrationWizard.createAccount(username: username,
|
||||
password: password,
|
||||
initialDeviceDisplayName: UIDevice.current.initialDisplayName)
|
||||
|
||||
guard !Task.isCancelled else { return }
|
||||
callback?(.completed(result))
|
||||
callback?(.completed(result: result, password: password))
|
||||
|
||||
self?.stopLoading()
|
||||
} catch {
|
||||
@@ -204,8 +209,9 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable {
|
||||
|
||||
/// Presents the server selection screen as a modal.
|
||||
@MainActor private func presentServerSelectionScreen() {
|
||||
MXLog.debug("[AuthenticationCoordinator] showServerSelectionScreen")
|
||||
MXLog.debug("[AuthenticationRegistrationCoordinator] presentServerSelectionScreen")
|
||||
let parameters = AuthenticationServerSelectionCoordinatorParameters(authenticationService: authenticationService,
|
||||
flow: .register,
|
||||
hasModalPresentation: true)
|
||||
let coordinator = AuthenticationServerSelectionCoordinator(parameters: parameters)
|
||||
coordinator.callback = { [weak self, weak coordinator] result in
|
||||
@@ -227,11 +233,7 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable {
|
||||
didCompleteWith result: AuthenticationServerSelectionCoordinatorResult) {
|
||||
if result == .updated {
|
||||
let homeserver = authenticationService.state.homeserver
|
||||
authenticationRegistrationViewModel.update(homeserverAddress: homeserver.addressFromUser ?? homeserver.address,
|
||||
showRegistrationForm: homeserver.registrationFlow != nil,
|
||||
ssoIdentityProviders: homeserver.preferredLoginMode.ssoIdentityProviders ?? [])
|
||||
|
||||
self.registrationWizard = authenticationService.registrationWizard
|
||||
authenticationRegistrationViewModel.update(homeserver: homeserver.viewData)
|
||||
}
|
||||
|
||||
navigationRouter.dismissModule(animated: true) { [weak self] in
|
||||
|
||||
@@ -28,6 +28,7 @@ enum MockAuthenticationRegistrationScreenState: MockScreenState, CaseIterable {
|
||||
case passwordWithCredentials
|
||||
case passwordWithUsernameError
|
||||
case ssoOnly
|
||||
case fallback
|
||||
|
||||
/// The associated screen
|
||||
var screenType: Any.Type {
|
||||
@@ -39,27 +40,21 @@ enum MockAuthenticationRegistrationScreenState: MockScreenState, CaseIterable {
|
||||
let viewModel: AuthenticationRegistrationViewModel
|
||||
switch self {
|
||||
case .matrixDotOrg:
|
||||
viewModel = AuthenticationRegistrationViewModel(homeserverAddress: "https://matrix.org", ssoIdentityProviders: [
|
||||
SSOIdentityProvider(id: "1", name: "Apple", brand: "apple", iconURL: nil),
|
||||
SSOIdentityProvider(id: "2", name: "Facebook", brand: "facebook", iconURL: nil),
|
||||
SSOIdentityProvider(id: "3", name: "GitHub", brand: "github", iconURL: nil),
|
||||
SSOIdentityProvider(id: "4", name: "GitLab", brand: "gitlab", iconURL: nil),
|
||||
SSOIdentityProvider(id: "5", name: "Google", brand: "google", iconURL: nil)
|
||||
])
|
||||
viewModel = AuthenticationRegistrationViewModel(homeserver: .mockMatrixDotOrg)
|
||||
case .passwordOnly:
|
||||
viewModel = AuthenticationRegistrationViewModel(homeserverAddress: "https://example.com", ssoIdentityProviders: [])
|
||||
viewModel = AuthenticationRegistrationViewModel(homeserver: .mockBasicServer)
|
||||
case .passwordWithCredentials:
|
||||
viewModel = AuthenticationRegistrationViewModel(homeserverAddress: "https://example.com", ssoIdentityProviders: [])
|
||||
viewModel = AuthenticationRegistrationViewModel(homeserver: .mockBasicServer)
|
||||
viewModel.context.username = "alice"
|
||||
viewModel.context.password = "password"
|
||||
case .passwordWithUsernameError:
|
||||
viewModel = AuthenticationRegistrationViewModel(homeserverAddress: "https://example.com", ssoIdentityProviders: [])
|
||||
viewModel = AuthenticationRegistrationViewModel(homeserver: .mockBasicServer)
|
||||
viewModel.state.hasEditedUsername = true
|
||||
Task { await viewModel.displayError(.usernameUnavailable(VectorL10n.authInvalidUserName)) }
|
||||
case .ssoOnly:
|
||||
viewModel = AuthenticationRegistrationViewModel(homeserverAddress: "https://company.com",
|
||||
showRegistrationForm: false,
|
||||
ssoIdentityProviders: [SSOIdentityProvider(id: "test", name: "SAML", brand: nil, iconURL: nil)])
|
||||
viewModel = AuthenticationRegistrationViewModel(homeserver: .mockEnterpriseSSO)
|
||||
case .fallback:
|
||||
viewModel = AuthenticationRegistrationViewModel(homeserver: .mockFallback)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -34,12 +34,14 @@ class AuthenticationRegistrationUITests: MockScreenTest {
|
||||
let state = "matrix.org"
|
||||
validateRegistrationFormIsVisible(for: state)
|
||||
validateSSOButtonsAreShown(for: state)
|
||||
validateFallbackButtonIsHidden(for: state)
|
||||
|
||||
validateNoErrorsAreShown(for: state)
|
||||
case .passwordOnly:
|
||||
let state = "a password only server"
|
||||
validateRegistrationFormIsVisible(for: state)
|
||||
validateSSOButtonsAreHidden(for: state)
|
||||
validateFallbackButtonIsHidden(for: state)
|
||||
|
||||
validateNextButtonIsDisabled(for: state)
|
||||
|
||||
@@ -48,6 +50,7 @@ class AuthenticationRegistrationUITests: MockScreenTest {
|
||||
let state = "a password only server with credentials entered"
|
||||
validateRegistrationFormIsVisible(for: state)
|
||||
validateSSOButtonsAreHidden(for: state)
|
||||
validateFallbackButtonIsHidden(for: state)
|
||||
|
||||
validateNextButtonIsEnabled(for: state)
|
||||
|
||||
@@ -56,6 +59,7 @@ class AuthenticationRegistrationUITests: MockScreenTest {
|
||||
let state = "a password only server with an invalid username"
|
||||
validateRegistrationFormIsVisible(for: state)
|
||||
validateSSOButtonsAreHidden(for: state)
|
||||
validateFallbackButtonIsHidden(for: state)
|
||||
|
||||
validateNextButtonIsDisabled(for: state)
|
||||
|
||||
@@ -64,6 +68,12 @@ class AuthenticationRegistrationUITests: MockScreenTest {
|
||||
let state = "an SSO only server"
|
||||
validateRegistrationFormIsHidden(for: state)
|
||||
validateSSOButtonsAreShown(for: state)
|
||||
validateFallbackButtonIsHidden(for: state)
|
||||
case .fallback:
|
||||
let state = "fallback"
|
||||
validateRegistrationFormIsHidden(for: state)
|
||||
validateSSOButtonsAreHidden(for: state)
|
||||
validateFallbackButtonIsShown(for: state)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +98,21 @@ class AuthenticationRegistrationUITests: MockScreenTest {
|
||||
XCTAssertFalse(passwordTextField.exists, "Password input should not be shown for \(state).")
|
||||
XCTAssertFalse(nextButton.exists, "The next button should not be shown for \(state).")
|
||||
}
|
||||
|
||||
/// Checks that the fallback button is hidden.
|
||||
func validateFallbackButtonIsHidden(for state: String) {
|
||||
let fallbackButton = app.buttons["fallbackButton"]
|
||||
|
||||
XCTAssertFalse(fallbackButton.exists, "The fallback button should not be shown for \(state).")
|
||||
}
|
||||
|
||||
/// Checks that the fallback button is hidden.
|
||||
func validateFallbackButtonIsShown(for state: String) {
|
||||
let fallbackButton = app.buttons["fallbackButton"]
|
||||
|
||||
XCTAssertTrue(fallbackButton.exists, "The fallback button should be shown for \(state).")
|
||||
XCTAssertTrue(fallbackButton.isEnabled, "The fallback button should be enabled for \(state).")
|
||||
}
|
||||
|
||||
/// Checks that there is at least one SSO button shown on the screen.
|
||||
func validateSSOButtonsAreShown(for state: String) {
|
||||
|
||||
@@ -20,79 +20,49 @@ import Combine
|
||||
@testable import RiotSwiftUI
|
||||
|
||||
@MainActor class AuthenticationRegistrationViewModelTests: XCTestCase {
|
||||
let defaultHomeserver = AuthenticationHomeserverViewData.mockMatrixDotOrg
|
||||
var viewModel: AuthenticationRegistrationViewModelProtocol!
|
||||
var context: AuthenticationRegistrationViewModelType.Context!
|
||||
|
||||
@MainActor override func setUp() async throws {
|
||||
viewModel = AuthenticationRegistrationViewModel(homeserverAddress: "", ssoIdentityProviders: [])
|
||||
viewModel = AuthenticationRegistrationViewModel(homeserver: defaultHomeserver)
|
||||
context = viewModel.context
|
||||
}
|
||||
|
||||
func testMatrixDotOrg() {
|
||||
// Given matrix.org with some SSO providers.
|
||||
let address = "https://matrix.org"
|
||||
let ssoProviders = [
|
||||
SSOIdentityProvider(id: "apple", name: "Apple", brand: "Apple", iconURL: nil),
|
||||
SSOIdentityProvider(id: "google", name: "Google", brand: "Google", iconURL: nil),
|
||||
SSOIdentityProvider(id: "github", name: "Github", brand: "Github", iconURL: nil)
|
||||
]
|
||||
// Given the initial view model configured for matrix.org with some SSO providers.
|
||||
let homeserver = defaultHomeserver
|
||||
|
||||
// When updating the view model with the server.
|
||||
viewModel.update(homeserverAddress: address, showRegistrationForm: true, ssoIdentityProviders: ssoProviders)
|
||||
|
||||
// Then the form should show the server description along with the username and password fields and the SSO buttons.
|
||||
XCTAssertEqual(context.viewState.homeserverAddress, "matrix.org", "The homeserver address should have the https scheme stripped away.")
|
||||
XCTAssertEqual(context.viewState.serverDescription, VectorL10n.authenticationRegistrationMatrixDescription, "A description should be shown for matrix.org.")
|
||||
XCTAssertTrue(context.viewState.showRegistrationForm, "The username and password section should be shown.")
|
||||
// Then the view state should contain a homeserver that matches matrix.org and shows SSO buttons.
|
||||
XCTAssertEqual(context.viewState.homeserver, homeserver, "The homeserver data should match the original.")
|
||||
XCTAssertTrue(context.viewState.showSSOButtons, "The SSO buttons should be shown.")
|
||||
}
|
||||
|
||||
func testBasicServer() {
|
||||
// Given a basic server example.com that only supports password registration.
|
||||
let address = "https://example.com"
|
||||
let homeserver = AuthenticationHomeserverViewData.mockBasicServer
|
||||
|
||||
// When updating the view model with the server.
|
||||
viewModel.update(homeserverAddress: address, showRegistrationForm: true, ssoIdentityProviders: [])
|
||||
viewModel.update(homeserver: homeserver)
|
||||
|
||||
// Then the form should only show the username and password section.
|
||||
XCTAssertEqual(context.viewState.homeserverAddress, "example.com", "The homeserver address should have the https scheme stripped away.")
|
||||
XCTAssertNil(context.viewState.serverDescription, "A description should not be shown when the server isn't matrix.org.")
|
||||
XCTAssertTrue(context.viewState.showRegistrationForm, "The username and password section should be shown.")
|
||||
// Then the view state should be updated with the homeserver and hide the SSO buttons.
|
||||
XCTAssertEqual(context.viewState.homeserver, homeserver, "The homeserver data should should match the new homeserver.")
|
||||
XCTAssertFalse(context.viewState.showSSOButtons, "The SSO buttons should not be shown.")
|
||||
}
|
||||
|
||||
func testFallbackServer() {
|
||||
// Given a basic server example.com that only supports password registration.
|
||||
let homeserver = AuthenticationHomeserverViewData.mockFallback
|
||||
|
||||
// When updating the view model with the server.
|
||||
viewModel.update(homeserver: homeserver)
|
||||
|
||||
// Then the view state should be updated with the homeserver and hide the SSO buttons and registration form.
|
||||
XCTAssertFalse(context.viewState.homeserver.showRegistrationForm, "The registration form should not be shown.")
|
||||
XCTAssertFalse(context.viewState.showSSOButtons, "The SSO buttons should not be shown.")
|
||||
}
|
||||
|
||||
func testUnsecureServer() {
|
||||
// Given a server that uses http for communication.
|
||||
let address = "http://testserver.local"
|
||||
|
||||
// When updating the view model with the server.
|
||||
viewModel.update(homeserverAddress: address, showRegistrationForm: true, ssoIdentityProviders: [])
|
||||
|
||||
// Then the form should only show the username and password section.
|
||||
XCTAssertEqual(context.viewState.homeserverAddress, address, "The homeserver address should show the http scheme.")
|
||||
XCTAssertNil(context.viewState.serverDescription, "A description should not be shown when the server isn't matrix.org.")
|
||||
}
|
||||
|
||||
func testSSOOnlyServer() {
|
||||
// Given matrix.org with some SSO providers.
|
||||
let address = "https://example.com"
|
||||
let ssoProviders = [
|
||||
SSOIdentityProvider(id: "apple", name: "Apple", brand: "Apple", iconURL: nil),
|
||||
SSOIdentityProvider(id: "google", name: "Google", brand: "Google", iconURL: nil),
|
||||
SSOIdentityProvider(id: "github", name: "Github", brand: "Github", iconURL: nil)
|
||||
]
|
||||
|
||||
// When updating the view model with the server.
|
||||
viewModel.update(homeserverAddress: address, showRegistrationForm: false, ssoIdentityProviders: ssoProviders)
|
||||
|
||||
// Then the form should show the server description along with the username and password fields and the SSO buttons.
|
||||
XCTAssertEqual(context.viewState.homeserverAddress, "example.com", "The homeserver address should have the https scheme stripped away.")
|
||||
XCTAssertNil(context.viewState.serverDescription, "A description should not be shown when the server isn't matrix.org.")
|
||||
XCTAssertFalse(context.viewState.showRegistrationForm, "The username and password section should not be shown.")
|
||||
XCTAssertTrue(context.viewState.showSSOButtons, "The SSO buttons should be shown.")
|
||||
}
|
||||
|
||||
func testUsernameError() async {
|
||||
func testUsernameError() async throws {
|
||||
// Given a form with a valid username.
|
||||
context.username = "bob"
|
||||
XCTAssertNil(context.viewState.usernameErrorMessage, "The shouldn't be a username error when the view model is created.")
|
||||
@@ -113,8 +83,7 @@ import Combine
|
||||
context.send(viewAction: .clearUsernameError)
|
||||
|
||||
// Wait for the action to spawn a Task on the main actor as the Context protocol doesn't support actors.
|
||||
let task = Task { try await Task.sleep(nanoseconds: 100_000_000) }
|
||||
_ = await task.result
|
||||
try await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Then the error should be hidden again.
|
||||
XCTAssertNil(context.viewState.usernameErrorMessage, "The shouldn't be a username error anymore.")
|
||||
|
||||
@@ -45,11 +45,11 @@ struct AuthenticationRegistrationScreen: View {
|
||||
.frame(height: 1)
|
||||
.padding(.vertical, 21)
|
||||
|
||||
if viewModel.viewState.showRegistrationForm {
|
||||
if viewModel.viewState.homeserver.showRegistrationForm {
|
||||
registrationForm
|
||||
}
|
||||
|
||||
if viewModel.viewState.showRegistrationForm && viewModel.viewState.showSSOButtons {
|
||||
if viewModel.viewState.homeserver.showRegistrationForm && viewModel.viewState.showSSOButtons {
|
||||
Text(VectorL10n.or)
|
||||
.foregroundColor(theme.colors.secondaryContent)
|
||||
.padding(.top, 16)
|
||||
@@ -59,6 +59,10 @@ struct AuthenticationRegistrationScreen: View {
|
||||
ssoButtons
|
||||
.padding(.top, 16)
|
||||
}
|
||||
|
||||
if !viewModel.viewState.homeserver.showRegistrationForm && !viewModel.viewState.showSSOButtons {
|
||||
fallbackButton
|
||||
}
|
||||
|
||||
}
|
||||
.readableFrame()
|
||||
@@ -90,35 +94,9 @@ struct AuthenticationRegistrationScreen: View {
|
||||
|
||||
/// The sever information section that includes a button to select a different server.
|
||||
var serverInfo: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(VectorL10n.authenticationRegistrationServerTitle)
|
||||
.font(theme.fonts.subheadline)
|
||||
.foregroundColor(theme.colors.secondaryContent)
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(viewModel.viewState.homeserverAddress)
|
||||
.font(theme.fonts.body)
|
||||
.foregroundColor(theme.colors.primaryContent)
|
||||
|
||||
if let serverDescription = viewModel.viewState.serverDescription {
|
||||
Text(serverDescription)
|
||||
.font(theme.fonts.caption1)
|
||||
.foregroundColor(theme.colors.tertiaryContent)
|
||||
.accessibilityIdentifier("serverDescriptionText")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button { viewModel.send(viewAction: .selectServer) } label: {
|
||||
Text(VectorL10n.edit)
|
||||
.font(theme.fonts.body)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(theme.colors.accent))
|
||||
}
|
||||
}
|
||||
AuthenticationServerInfoSection(address: viewModel.viewState.homeserver.address,
|
||||
showMatrixDotOrgInfo: viewModel.viewState.homeserver.isMatrixDotOrg) {
|
||||
viewModel.send(viewAction: .selectServer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +112,8 @@ struct AuthenticationRegistrationScreen: View {
|
||||
configuration: UIKitTextInputConfiguration(returnKeyType: .next,
|
||||
autocapitalizationType: .none,
|
||||
autocorrectionType: .no),
|
||||
onEditingChanged: usernameEditingChanged)
|
||||
onEditingChanged: usernameEditingChanged,
|
||||
onCommit: { isPasswordFocused = true })
|
||||
.onChange(of: viewModel.username) { _ in viewModel.send(viewAction: .clearUsernameError) }
|
||||
.accessibilityIdentifier("usernameTextField")
|
||||
|
||||
@@ -146,7 +125,8 @@ struct AuthenticationRegistrationScreen: View {
|
||||
isFirstResponder: isPasswordFocused,
|
||||
configuration: UIKitTextInputConfiguration(returnKeyType: .done,
|
||||
isSecureTextEntry: true),
|
||||
onEditingChanged: passwordEditingChanged)
|
||||
onEditingChanged: passwordEditingChanged,
|
||||
onCommit: submit)
|
||||
.accessibilityIdentifier("passwordTextField")
|
||||
|
||||
Button(action: submit) {
|
||||
@@ -161,28 +141,35 @@ struct AuthenticationRegistrationScreen: View {
|
||||
/// A list of SSO buttons that can be used for login.
|
||||
var ssoButtons: some View {
|
||||
VStack(spacing: 16) {
|
||||
ForEach(viewModel.viewState.ssoIdentityProviders) { provider in
|
||||
ForEach(viewModel.viewState.homeserver.ssoIdentityProviders) { provider in
|
||||
AuthenticationSSOButton(provider: provider) {
|
||||
viewModel.send(viewAction: .continueWithSSO(id: provider.id))
|
||||
viewModel.send(viewAction: .continueWithSSO(provider))
|
||||
}
|
||||
.accessibilityIdentifier("ssoButton")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates the username when the text field ends editing, and selects the password text field.
|
||||
func usernameEditingChanged(isEditing: Bool) {
|
||||
guard !isEditing, !viewModel.username.isEmpty else { return }
|
||||
|
||||
viewModel.send(viewAction: .validateUsername)
|
||||
isPasswordFocused = true
|
||||
|
||||
/// A fallback button that can be used for login.
|
||||
var fallbackButton: some View {
|
||||
Button(action: fallback) {
|
||||
Text(VectorL10n.authRegister)
|
||||
}
|
||||
.buttonStyle(PrimaryActionButtonStyle())
|
||||
.accessibilityIdentifier("fallbackButton")
|
||||
}
|
||||
|
||||
/// Enables password validation the first time the user taps return, and sends the username and submits the form if possible.
|
||||
/// Validates the username when the text field ends editing.
|
||||
func usernameEditingChanged(isEditing: Bool) {
|
||||
guard !isEditing, !viewModel.username.isEmpty else { return }
|
||||
viewModel.send(viewAction: .validateUsername)
|
||||
}
|
||||
|
||||
/// Enables password validation the first time the user finishes editing.
|
||||
/// Additionally resets the password field focus.
|
||||
func passwordEditingChanged(isEditing: Bool) {
|
||||
guard !isEditing else { return }
|
||||
isPasswordFocused = false
|
||||
submit()
|
||||
|
||||
guard !viewModel.viewState.hasEditedPassword else { return }
|
||||
viewModel.send(viewAction: .enablePasswordValidation)
|
||||
@@ -193,6 +180,11 @@ struct AuthenticationRegistrationScreen: View {
|
||||
guard viewModel.viewState.hasValidCredentials else { return }
|
||||
viewModel.send(viewAction: .next)
|
||||
}
|
||||
|
||||
/// Sends the `fallback` view action.
|
||||
func fallback() {
|
||||
viewModel.send(viewAction: .fallback)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
@@ -33,7 +33,7 @@ class AuthenticationServerSelectionViewModel: AuthenticationServerSelectionViewM
|
||||
// MARK: - Setup
|
||||
|
||||
init(homeserverAddress: String, hasModalPresentation: Bool) {
|
||||
let bindings = AuthenticationServerSelectionBindings(homeserverAddress: HomeserverAddress.displayable(homeserverAddress))
|
||||
let bindings = AuthenticationServerSelectionBindings(homeserverAddress: homeserverAddress)
|
||||
super.init(initialViewState: AuthenticationServerSelectionViewState(bindings: bindings,
|
||||
hasModalPresentation: hasModalPresentation))
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import CommonKit
|
||||
|
||||
struct AuthenticationServerSelectionCoordinatorParameters {
|
||||
let authenticationService: AuthenticationService
|
||||
/// Whether the server selection is for the login flow or registration flow.
|
||||
let flow: AuthenticationFlow
|
||||
/// Whether the screen is presented modally or within a navigation stack.
|
||||
let hasModalPresentation: Bool
|
||||
}
|
||||
@@ -56,7 +58,7 @@ final class AuthenticationServerSelectionCoordinator: Coordinator, Presentable {
|
||||
self.parameters = parameters
|
||||
|
||||
let homeserver = parameters.authenticationService.state.homeserver
|
||||
let viewModel = AuthenticationServerSelectionViewModel(homeserverAddress: homeserver.addressFromUser ?? homeserver.address,
|
||||
let viewModel = AuthenticationServerSelectionViewModel(homeserverAddress: homeserver.displayableAddress,
|
||||
hasModalPresentation: parameters.hasModalPresentation)
|
||||
let view = AuthenticationServerSelectionScreen(viewModel: viewModel.context)
|
||||
authenticationServerSelectionViewModel = viewModel
|
||||
@@ -111,14 +113,12 @@ final class AuthenticationServerSelectionCoordinator: Coordinator, Presentable {
|
||||
/// Updates the login flow using the supplied homeserver address, or shows an error when this isn't possible.
|
||||
@MainActor private func useHomeserver(_ homeserverAddress: String) {
|
||||
startLoading()
|
||||
authenticationService.reset()
|
||||
|
||||
let homeserverAddress = HomeserverAddress.sanitized(homeserverAddress)
|
||||
|
||||
Task {
|
||||
do {
|
||||
#warning("The screen should be configuration for .login too.")
|
||||
try await authenticationService.startFlow(.register, for: homeserverAddress)
|
||||
try await authenticationService.startFlow(parameters.flow, for: homeserverAddress)
|
||||
stopLoading()
|
||||
|
||||
callback?(.updated)
|
||||
|
||||
@@ -31,7 +31,7 @@ class AuthenticationServerSelectionViewModelTests: XCTestCase {
|
||||
context = viewModel.context
|
||||
}
|
||||
|
||||
@MainActor func testErrorMessage() async {
|
||||
@MainActor func testErrorMessage() async throws {
|
||||
// Given a new instance of the view model.
|
||||
XCTAssertNil(context.viewState.footerErrorMessage, "There should not be an error message for a new view model.")
|
||||
XCTAssertEqual(context.viewState.footerMessage, VectorL10n.authenticationServerSelectionServerFooter, "The standard footer message should be shown.")
|
||||
@@ -48,8 +48,7 @@ class AuthenticationServerSelectionViewModelTests: XCTestCase {
|
||||
context.send(viewAction: .clearFooterError)
|
||||
|
||||
// Wait for the action to spawn a Task on the main actor as the Context protocol doesn't support actors.
|
||||
let task = Task { try await Task.sleep(nanoseconds: 100_000_000) }
|
||||
_ = await task.result
|
||||
try await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Then the error message should now be removed.
|
||||
XCTAssertNil(context.viewState.footerErrorMessage, "The error message should have been cleared.")
|
||||
|
||||
@@ -59,7 +59,7 @@ final class AuthenticationTermsCoordinator: Coordinator, Presentable {
|
||||
@MainActor init(parameters: AuthenticationTermsCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
let subtitle = HomeserverAddress.displayable(parameters.homeserverAddress)
|
||||
let subtitle = parameters.homeserverAddress
|
||||
let policies = parameters.localizedPolicies.compactMap { AuthenticationTermsPolicy(url: $0.url, title: $0.name, subtitle: subtitle) }
|
||||
|
||||
let viewModel = AuthenticationTermsViewModel(policies: policies)
|
||||
|
||||
@@ -21,6 +21,7 @@ import Foundation
|
||||
enum MockAppScreens {
|
||||
static let appScreens: [MockScreenState.Type] = [
|
||||
MockLiveLocationSharingViewerScreenState.self,
|
||||
MockAuthenticationLoginScreenState.self,
|
||||
MockAuthenticationReCaptchaScreenState.self,
|
||||
MockAuthenticationTermsScreenState.self,
|
||||
MockAuthenticationVerifyEmailScreenState.self,
|
||||
|
||||
@@ -18,15 +18,25 @@ import SwiftUI
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
extension ThemableTextField {
|
||||
func showClearButton(text: Binding<String>, alignement: VerticalAlignment = .center) -> some View {
|
||||
return modifier(ClearViewModifier(alignment: alignement, text: text))
|
||||
/// Adds a clear button to the text field
|
||||
/// - Parameters:
|
||||
/// - show: A boolean that can be used to dynamically show/hide the button. Defaults to `true`.
|
||||
/// - text: The text for the clear button to clear.
|
||||
/// - alignment: The vertical alignment of the button in the text field. Default to `center`
|
||||
@ViewBuilder
|
||||
func showClearButton(_ show: Bool = true, text: Binding<String>, alignment: VerticalAlignment = .center) -> some View {
|
||||
if show {
|
||||
modifier(ClearViewModifier(alignment: alignment, text: text))
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
extension ThemableTextEditor {
|
||||
func showClearButton(text: Binding<String>, alignement: VerticalAlignment = .top) -> some View {
|
||||
return modifier(ClearViewModifier(alignment: alignement, text: text))
|
||||
func showClearButton(text: Binding<String>, alignment: VerticalAlignment = .top) -> some View {
|
||||
return modifier(ClearViewModifier(alignment: alignment, text: text))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ struct RoundedBorderTextField: View {
|
||||
|
||||
var onTextChanged: ((String) -> Void)? = nil
|
||||
var onEditingChanged: ((Bool) -> Void)? = nil
|
||||
var onCommit: (() -> Void)? = nil
|
||||
|
||||
// MARK: Private
|
||||
|
||||
@@ -52,6 +53,7 @@ struct RoundedBorderTextField: View {
|
||||
.multilineTextAlignment(.leading)
|
||||
.padding(EdgeInsets(top: 0, leading: 0, bottom: 8, trailing: 0))
|
||||
}
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
if text.isEmpty {
|
||||
Text(placeHolder)
|
||||
@@ -60,32 +62,22 @@ struct RoundedBorderTextField: View {
|
||||
.lineLimit(1)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
if isEnabled {
|
||||
ThemableTextField(placeholder: "", text: $text, configuration: configuration, onEditingChanged: { edit in
|
||||
self.editing = edit
|
||||
onEditingChanged?(edit)
|
||||
})
|
||||
.makeFirstResponder(isFirstResponder)
|
||||
.showClearButton(text: $text)
|
||||
.onChange(of: text) { newText in
|
||||
onTextChanged?(newText)
|
||||
}
|
||||
.frame(height: 30)
|
||||
.accessibilityLabel(text.isEmpty ? placeHolder : "")
|
||||
} else {
|
||||
ThemableTextField(placeholder: "", text: $text, configuration: configuration, onEditingChanged: { edit in
|
||||
self.editing = edit
|
||||
onEditingChanged?(edit)
|
||||
})
|
||||
.makeFirstResponder(isFirstResponder)
|
||||
.onChange(of: text) { newText in
|
||||
onTextChanged?(newText)
|
||||
}
|
||||
.frame(height: 30)
|
||||
.allowsHitTesting(false)
|
||||
.opacity(0.5)
|
||||
.accessibilityLabel(text.isEmpty ? placeHolder : "")
|
||||
|
||||
ThemableTextField(placeholder: "", text: $text, configuration: configuration, onEditingChanged: { edit in
|
||||
self.editing = edit
|
||||
onEditingChanged?(edit)
|
||||
}, onCommit: {
|
||||
onCommit?()
|
||||
})
|
||||
.makeFirstResponder(isFirstResponder)
|
||||
.showClearButton(isEnabled, text: $text)
|
||||
.onChange(of: text) { newText in
|
||||
onTextChanged?(newText)
|
||||
}
|
||||
.frame(height: 30)
|
||||
.allowsHitTesting(isEnabled)
|
||||
.opacity(isEnabled ? 1 : 0.5)
|
||||
.accessibilityLabel(text.isEmpty ? placeHolder : "")
|
||||
}
|
||||
.padding(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: text.isEmpty ? 8 : 0))
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(theme.colors.background))
|
||||
|
||||
@@ -27,25 +27,24 @@ struct SecondaryActionButtonStyle: ButtonStyle {
|
||||
configuration.label
|
||||
.padding(12.0)
|
||||
.frame(maxWidth: .infinity)
|
||||
.foregroundColor(strokeColor(configuration.isPressed))
|
||||
.foregroundColor(customColor ?? theme.colors.accent)
|
||||
.font(theme.fonts.body)
|
||||
.background(RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder()
|
||||
.foregroundColor(strokeColor(configuration.isPressed)))
|
||||
.opacity(isEnabled ? 1.0 : 0.6)
|
||||
.foregroundColor(customColor ?? theme.colors.accent))
|
||||
.opacity(opacity(when: configuration.isPressed))
|
||||
}
|
||||
|
||||
func strokeColor(_ isPressed: Bool) -> Color {
|
||||
if let customColor = customColor {
|
||||
return customColor
|
||||
}
|
||||
|
||||
return isPressed ? theme.colors.accent.opacity(0.6) : theme.colors.accent
|
||||
private func opacity(when isPressed: Bool) -> CGFloat {
|
||||
guard isEnabled else { return 0.6 }
|
||||
return isPressed ? 0.6 : 1.0
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
struct SecondaryActionButtonStyle_Previews: PreviewProvider {
|
||||
static var theme: ThemeSwiftUI = DefaultThemeSwiftUI()
|
||||
|
||||
static var previews: some View {
|
||||
Group {
|
||||
buttonGroup
|
||||
@@ -64,14 +63,14 @@ struct SecondaryActionButtonStyle_Previews: PreviewProvider {
|
||||
.buttonStyle(SecondaryActionButtonStyle())
|
||||
.disabled(true)
|
||||
|
||||
Button { } label: {
|
||||
Text("Clear BG")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
.buttonStyle(SecondaryActionButtonStyle(customColor: .clear))
|
||||
|
||||
Button("Red BG") { }
|
||||
.buttonStyle(SecondaryActionButtonStyle(customColor: .red))
|
||||
|
||||
Button { } label: {
|
||||
Text("Custom")
|
||||
.foregroundColor(theme.colors.secondaryContent)
|
||||
}
|
||||
.buttonStyle(SecondaryActionButtonStyle(customColor: theme.colors.quarterlyContent))
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CommonKit
|
||||
|
||||
protocol OnboardingSplashScreenCoordinatorProtocol: Coordinator, Presentable {
|
||||
var completion: ((OnboardingSplashScreenViewModelResult) -> Void)? { get set }
|
||||
@@ -29,6 +30,9 @@ final class OnboardingSplashScreenCoordinator: OnboardingSplashScreenCoordinator
|
||||
private let onboardingSplashScreenHostingController: VectorHostingController
|
||||
private var onboardingSplashScreenViewModel: OnboardingSplashScreenViewModelProtocol
|
||||
|
||||
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
|
||||
private var loadingIndicator: UserIndicator?
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// Must be used only internally
|
||||
@@ -43,6 +47,8 @@ final class OnboardingSplashScreenCoordinator: OnboardingSplashScreenCoordinator
|
||||
onboardingSplashScreenViewModel = viewModel
|
||||
onboardingSplashScreenHostingController = VectorHostingController(rootView: view)
|
||||
onboardingSplashScreenHostingController.vc_removeBackTitle()
|
||||
|
||||
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingSplashScreenHostingController)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
@@ -52,13 +58,33 @@ final class OnboardingSplashScreenCoordinator: OnboardingSplashScreenCoordinator
|
||||
MXLog.debug("[OnboardingSplashScreenCoordinator] OnboardingSplashScreenViewModel did complete with result: \(result).")
|
||||
guard let self = self else { return }
|
||||
switch result {
|
||||
case .login, .register:
|
||||
case .login:
|
||||
self.startLoading()
|
||||
self.completion?(result)
|
||||
case .register:
|
||||
self.completion?(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
return self.onboardingSplashScreenHostingController
|
||||
return onboardingSplashScreenHostingController
|
||||
}
|
||||
|
||||
/// Stops any ongoing activities in the coordinator.
|
||||
func stop() {
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Show an activity indicator whilst loading.
|
||||
private func startLoading() {
|
||||
loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true))
|
||||
}
|
||||
|
||||
/// Hide the currently displayed activity indicator.
|
||||
private func stopLoading() {
|
||||
loadingIndicator = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,9 @@ final class OnboardingUseCaseSelectionCoordinator: Coordinator, Presentable {
|
||||
MXLog.debug("[OnboardingUseCaseSelectionCoordinator] OnboardingUseCaseViewModel did complete with result: \(result).")
|
||||
|
||||
// Show a loading indicator which can be dismissed externally by calling `stop`.
|
||||
self.startLoading()
|
||||
if result != .customServer {
|
||||
self.startLoading()
|
||||
}
|
||||
self.completion?(result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ enum LocationSharingViewAction {
|
||||
case goToUserLocation
|
||||
case startLiveSharing
|
||||
case shareLiveLocation(timeout: LiveLocationSharingTimeout)
|
||||
case userDidPan
|
||||
}
|
||||
|
||||
enum LocationSharingViewModelResult {
|
||||
@@ -70,9 +71,7 @@ struct LocationSharingViewState: BindableState {
|
||||
var highlightedAnnotation: LocationAnnotation?
|
||||
|
||||
/// Indicates whether the user has moved around the map to drop a pin somewhere other than their current location
|
||||
var isPinDropSharing: Bool {
|
||||
return bindings.pinLocation != nil
|
||||
}
|
||||
var isPinDropSharing: Bool = false
|
||||
|
||||
var showLoadingIndicator: Bool = false
|
||||
|
||||
|
||||
@@ -78,12 +78,16 @@ class LocationSharingViewModel: LocationSharingViewModelType, LocationSharingVie
|
||||
|
||||
completion?(.share(latitude: pinLocation.latitude, longitude: pinLocation.longitude, coordinateType: .pin))
|
||||
case .goToUserLocation:
|
||||
state.bindings.pinLocation = nil
|
||||
state.showsUserLocation = true
|
||||
state.isPinDropSharing = false
|
||||
case .startLiveSharing:
|
||||
self.startLiveLocationSharing()
|
||||
case .shareLiveLocation(let timeout):
|
||||
state.bindings.showingTimerSelector = false
|
||||
completion?(.shareLiveLocation(timeout: timeout.rawValue))
|
||||
case .userDidPan:
|
||||
state.showsUserLocation = false
|
||||
state.isPinDropSharing = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,9 @@ struct LocationSharingMapView: UIViewRepresentable {
|
||||
|
||||
/// Publish view errors if any
|
||||
let errorSubject: PassthroughSubject<LocationSharingViewError, Never>
|
||||
|
||||
/// Called when the user pan on the map
|
||||
var userDidPan: (() -> Void)?
|
||||
|
||||
// MARK: - UIViewRepresentable
|
||||
|
||||
@@ -65,6 +68,9 @@ struct LocationSharingMapView: UIViewRepresentable {
|
||||
|
||||
let mapView = self.makeMapView()
|
||||
mapView.delegate = context.coordinator
|
||||
let panGesture = UIPanGestureRecognizer(target: context.coordinator, action: #selector(context.coordinator.didPan))
|
||||
panGesture.delegate = context.coordinator
|
||||
mapView.addGestureRecognizer(panGesture)
|
||||
return mapView
|
||||
}
|
||||
|
||||
@@ -77,7 +83,7 @@ struct LocationSharingMapView: UIViewRepresentable {
|
||||
mapView.setCenter(highlightedAnnotation.coordinate, zoomLevel: Constants.mapZoomLevel, animated: false)
|
||||
}
|
||||
|
||||
if self.showsUserLocation && mapCenterCoordinate == nil {
|
||||
if self.showsUserLocation {
|
||||
mapView.showsUserLocation = true
|
||||
mapView.userTrackingMode = .follow
|
||||
} else {
|
||||
@@ -106,7 +112,7 @@ struct LocationSharingMapView: UIViewRepresentable {
|
||||
@available(iOS 14, *)
|
||||
extension LocationSharingMapView {
|
||||
|
||||
class Coordinator: NSObject, MGLMapViewDelegate {
|
||||
class Coordinator: NSObject, MGLMapViewDelegate, UIGestureRecognizerDelegate {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
@@ -126,7 +132,7 @@ extension LocationSharingMapView {
|
||||
return LocationAnnotationView(userLocationAnnotation: userLocationAnnotation)
|
||||
} else if let pinLocationAnnotation = annotation as? PinLocationAnnotation {
|
||||
return LocationAnnotationView(pinLocationAnnotation: pinLocationAnnotation)
|
||||
} else if annotation is MGLUserLocation && locationSharingMapView.mapCenterCoordinate == nil, let currentUserAvatarData = locationSharingMapView.userAvatarData {
|
||||
} else if annotation is MGLUserLocation, let currentUserAvatarData = locationSharingMapView.userAvatarData {
|
||||
// Replace default current location annotation view with a UserLocationAnnotatonView when the map is center on user location
|
||||
return LocationAnnotationView(avatarData: currentUserAvatarData)
|
||||
}
|
||||
@@ -158,13 +164,7 @@ extension LocationSharingMapView {
|
||||
}
|
||||
|
||||
func mapView(_ mapView: MGLMapView, regionDidChangeAnimated animated: Bool) {
|
||||
let mapCenterCoordinate = mapView.centerCoordinate
|
||||
// Prevent this function to set pinLocation when the map is openning
|
||||
guard let userLocation = locationSharingMapView.userLocation,
|
||||
!userLocation.isEqual(to: mapCenterCoordinate, precision: 0.0000000001) else {
|
||||
return
|
||||
}
|
||||
locationSharingMapView.mapCenterCoordinate = mapCenterCoordinate
|
||||
locationSharingMapView.mapCenterCoordinate = mapView.centerCoordinate
|
||||
}
|
||||
|
||||
// MARK: Callout
|
||||
@@ -182,11 +182,21 @@ extension LocationSharingMapView {
|
||||
}
|
||||
|
||||
func mapView(_ mapView: MGLMapView, tapOnCalloutFor annotation: MGLAnnotation) {
|
||||
|
||||
locationSharingMapView.onCalloutTap?(annotation)
|
||||
// Hide the callout
|
||||
mapView.deselectAnnotation(annotation, animated: true)
|
||||
}
|
||||
|
||||
// MARK: UIGestureRecognizer
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
return gestureRecognizer is UIPanGestureRecognizer
|
||||
}
|
||||
|
||||
@objc
|
||||
func didPan() {
|
||||
locationSharingMapView.userDidPan?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,10 @@ struct LocationSharingView: View {
|
||||
showsUserLocation: context.viewState.showsUserLocation,
|
||||
userLocation: $context.userLocation,
|
||||
mapCenterCoordinate: $context.pinLocation,
|
||||
errorSubject: context.viewState.errorSubject)
|
||||
errorSubject: context.viewState.errorSubject,
|
||||
userDidPan: {
|
||||
context.send(viewAction: .userDidPan)
|
||||
})
|
||||
if context.viewState.isPinDropSharing {
|
||||
LocationSharingMarkerView(backgroundColor: theme.colors.accent) {
|
||||
Image(uiImage: Asset.Images.locationPinIcon.image)
|
||||
|
||||
@@ -41,7 +41,9 @@ class HomeserverConfigurationTests: XCTestCase {
|
||||
let expectedSecureBackupRequired = true
|
||||
let secureBackupSetupMethods = ["passphrase"]
|
||||
let expectedSecureBackupSetupMethods: [VectorWellKnownBackupSetupMethod] = [.passphrase]
|
||||
|
||||
let outboundKeysPreSharingMode = "on_room_opening"
|
||||
let expectedOutboundKeysPreSharingMode: MXKKeyPreSharingStrategy = .whenEnteringRoom
|
||||
|
||||
let wellKnownDictionary: [String: Any] = [
|
||||
"m.homeserver": [
|
||||
"base_url": "https://your.homeserver.org"
|
||||
@@ -61,7 +63,8 @@ class HomeserverConfigurationTests: XCTestCase {
|
||||
"io.element.e2ee" : [
|
||||
"default" : expectedE2EEEByDefaultEnabled,
|
||||
"secure_backup_required": expectedSecureBackupRequired,
|
||||
"secure_backup_setup_methods": secureBackupSetupMethods
|
||||
"secure_backup_setup_methods": secureBackupSetupMethods,
|
||||
"outbound_keys_pre_sharing_mode": outboundKeysPreSharingMode
|
||||
],
|
||||
"io.element.jitsi" : [
|
||||
"preferredDomain" : expectedJitsiServer
|
||||
@@ -78,6 +81,8 @@ class HomeserverConfigurationTests: XCTestCase {
|
||||
XCTAssertEqual(homeserverConfiguration.encryption.isE2EEByDefaultEnabled, expectedE2EEEByDefaultEnabled)
|
||||
XCTAssertEqual(homeserverConfiguration.encryption.isSecureBackupRequired, expectedSecureBackupRequired)
|
||||
XCTAssertEqual(homeserverConfiguration.encryption.secureBackupSetupMethods, expectedSecureBackupSetupMethods)
|
||||
XCTAssertEqual(homeserverConfiguration.encryption.outboundKeysPreSharingMode, expectedOutboundKeysPreSharingMode)
|
||||
|
||||
XCTAssertEqual(homeserverConfiguration.tileServer.mapStyleURL.absoluteString, expectedMapStyleURLString)
|
||||
}
|
||||
|
||||
@@ -86,6 +91,7 @@ class HomeserverConfigurationTests: XCTestCase {
|
||||
let expectedE2EEEByDefaultEnabled = true
|
||||
let expectedSecureBackupRequired = false
|
||||
let expectedSecureBackupSetupMethods: [VectorWellKnownBackupSetupMethod] = [.passphrase, .key]
|
||||
let expectedOutboundKeysPreSharingMode: MXKKeyPreSharingStrategy = .whenTyping
|
||||
|
||||
let wellKnownDictionary: [String: Any] = [
|
||||
"m.homeserver": [
|
||||
@@ -104,5 +110,6 @@ class HomeserverConfigurationTests: XCTestCase {
|
||||
XCTAssertEqual(homeserverConfiguration.encryption.isE2EEByDefaultEnabled, expectedE2EEEByDefaultEnabled)
|
||||
XCTAssertEqual(homeserverConfiguration.encryption.isSecureBackupRequired, expectedSecureBackupRequired)
|
||||
XCTAssertEqual(homeserverConfiguration.encryption.secureBackupSetupMethods, expectedSecureBackupSetupMethods)
|
||||
XCTAssertEqual(homeserverConfiguration.encryption.outboundKeysPreSharingMode, expectedOutboundKeysPreSharingMode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,4 +81,90 @@ class AuthenticationServiceTests: XCTestCase {
|
||||
XCTAssertEqual(service.state.homeserver.addressFromUser, "https://matrix.org", "The new address entered by the user should be stored.")
|
||||
XCTAssertEqual(service.state.homeserver.address, "https://matrix-client.matrix.org", "The new address discovered from the well-known should be stored.")
|
||||
}
|
||||
|
||||
func testHomeserverViewDataForMatrixDotOrg() {
|
||||
// Given a homeserver such as matrix.org.
|
||||
let address = "https://matrix-client.matrix.org"
|
||||
let addressFromUser = "https://matrix.org" // https is added when sanitising the input.
|
||||
let ssoIdentityProviders = [
|
||||
SSOIdentityProvider(id: "1", name: "Apple", brand: "apple", iconURL: nil),
|
||||
SSOIdentityProvider(id: "2", name: "GitHub", brand: "github", iconURL: nil)
|
||||
]
|
||||
let flowResult = FlowResult(missingStages: [.email(isMandatory: true), .reCaptcha(isMandatory: true, siteKey: "1234")], completedStages: [])
|
||||
let homeserver = AuthenticationState.Homeserver(address: address,
|
||||
addressFromUser: addressFromUser,
|
||||
preferredLoginMode: .ssoAndPassword(ssoIdentityProviders: ssoIdentityProviders),
|
||||
registrationFlow: .flowResponse(flowResult))
|
||||
|
||||
// When creating view data for that homeserver.
|
||||
let viewData = homeserver.viewData
|
||||
|
||||
// Then the view data should correctly represent the homeserver.
|
||||
XCTAssertEqual(viewData.address, "matrix.org", "The displayed address should match the address supplied by the user, but without the scheme.")
|
||||
XCTAssertEqual(viewData.isMatrixDotOrg, true, "The server should be detected as matrix.org.")
|
||||
XCTAssertTrue(viewData.showLoginForm, "The login form should be shown.")
|
||||
XCTAssertEqual(viewData.ssoIdentityProviders, ssoIdentityProviders, "The sso identity providers should match.")
|
||||
XCTAssertTrue(viewData.showRegistrationForm, "The registration form should be shown.")
|
||||
}
|
||||
|
||||
func testHomeserverViewDataForPasswordLoginOnly() {
|
||||
// Given a homeserver with password login and registration disabled.
|
||||
let address = "https://matrix.example.com"
|
||||
let addressFromUser = "https://example.com" // https is added when sanitising the input.
|
||||
let homeserver = AuthenticationState.Homeserver(address: address,
|
||||
addressFromUser: addressFromUser,
|
||||
preferredLoginMode: .password,
|
||||
registrationFlow: nil)
|
||||
|
||||
// When creating view data for that homeserver.
|
||||
let viewData = homeserver.viewData
|
||||
|
||||
// Then the view data should correctly represent the homeserver.
|
||||
XCTAssertEqual(viewData.address, "example.com", "The displayed address should match the address supplied by the user, but without the scheme.")
|
||||
XCTAssertEqual(viewData.isMatrixDotOrg, false, "The server should not be detected as matrix.org.")
|
||||
XCTAssertTrue(viewData.showLoginForm, "The login form should be shown.")
|
||||
XCTAssertEqual(viewData.ssoIdentityProviders, [], "There shouldn't be any sso identity providers.")
|
||||
XCTAssertFalse(viewData.showRegistrationForm, "The registration form should not be shown.")
|
||||
}
|
||||
|
||||
func testHomeserverViewDataForSSOOnly() {
|
||||
// Given a homeserver that only supports authentication via SSO.
|
||||
let address = "https://matrix.company.com"
|
||||
let addressFromUser = "https://company.com" // https is added when sanitising the input.
|
||||
let ssoIdentityProviders = [SSOIdentityProvider(id: "1", name: "SAML", brand: nil, iconURL: nil)]
|
||||
let homeserver = AuthenticationState.Homeserver(address: address,
|
||||
addressFromUser: addressFromUser,
|
||||
preferredLoginMode: .sso(ssoIdentityProviders: ssoIdentityProviders),
|
||||
registrationFlow: nil)
|
||||
|
||||
// When creating view data for that homeserver.
|
||||
let viewData = homeserver.viewData
|
||||
|
||||
// Then the view data should correctly represent the homeserver.
|
||||
XCTAssertEqual(viewData.address, "company.com", "The displayed address should match the address supplied by the user, but without the scheme.")
|
||||
XCTAssertEqual(viewData.isMatrixDotOrg, false, "The server should not be detected as matrix.org.")
|
||||
XCTAssertFalse(viewData.showLoginForm, "The login form should not be shown.")
|
||||
XCTAssertEqual(viewData.ssoIdentityProviders, ssoIdentityProviders, "The sso identity providers should match.")
|
||||
XCTAssertFalse(viewData.showRegistrationForm, "The registration form should not be shown.")
|
||||
}
|
||||
|
||||
func testHomeserverViewDataForLocalHomeserver() {
|
||||
// Given a local homeserver that supports login and registration but only via a password.
|
||||
let addressFromUser = "http://localhost:8008" // https is added when sanitising the input.
|
||||
let flowResult = FlowResult(missingStages: [.dummy(isMandatory: true)], completedStages: [])
|
||||
let homeserver = AuthenticationState.Homeserver(address: addressFromUser,
|
||||
addressFromUser: addressFromUser,
|
||||
preferredLoginMode: .password,
|
||||
registrationFlow: .flowResponse(flowResult))
|
||||
|
||||
// When creating view data for that homeserver.
|
||||
let viewData = homeserver.viewData
|
||||
|
||||
// Then the view data should correctly represent the homeserver.
|
||||
XCTAssertEqual(viewData.address, "http://localhost:8008", "The displayed address should match address supplied by the user, complete with the scheme.")
|
||||
XCTAssertEqual(viewData.isMatrixDotOrg, false, "The server should not be detected as matrix.org.")
|
||||
XCTAssertTrue(viewData.showLoginForm, "The login form should be shown.")
|
||||
XCTAssertEqual(viewData.ssoIdentityProviders, [], "There shouldn't be any sso identity providers.")
|
||||
XCTAssertTrue(viewData.showRegistrationForm, "The registration form should be shown.")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user