From 2f88e04a66bb3f1444763f7e3c103353b9be6f77 Mon Sep 17 00:00:00 2001 From: Andi <andreas.bauer@stanford.edu> Date: Mon, 23 Oct 2023 11:13:19 -0700 Subject: [PATCH] Infrastructure changes to allow for seamless Single-Sign-On implementations. (#28) # Infrastructure changes to allow for seamless Single-Sign-On implementations. ## :recycle: Current situation & Problem While `IdentityProvider` are currently supported by `SpeziAccount`, there are some issues when trying to accommodate more extended use cases or certain limitations when combining different functionality. Below is a list of certain pain points including general improvements this PR makes: * Currently, account values requirements are globally applied. E.g., a configured password key is required for **all** account services. This doesn't fit very well for SSO providers as they intentionally don't collect passwords. Instead one would need a way of declaring AccountKeys required in the context of a given account services. This is resolved by changing the effect of the `RequiredAccountKeys` configuration an account service would provide and consequentially removing the need for the user to declare the `password` account value globally required. * We added a new `FollowUpInfoSheet` that automatically pops up after account setup to assist the user to provide any additional account details that are configured to be required. That is especially useful when using Identity Provider like Sign in with Apple that only provide a fixed set of user information. A new `verifyRequiredAccountDetails(_:)` modifier was added to implement the same logic on the global application level. This can be used that existing users always are in line with the latest configuration of your app. * The `accountRequired(_:setupSheet:)` modifier was added to enforce a user account at all times. * Added a new globally unique and stable `AccountId`, mandatory for each user account. * Fixed an issue where the SignupForm cancel confirmation would always pop up when using AccountKeys with default values. * Other fixes and improvements. ## :gear: Release Notes Added several infrastructure enhancements that allow for an improved user experience and compatibility when using identity providers. ### Breaking Changes * We introduce a new `accountId` (see `AccountIdKey`) account value that is mandatory for all user accounts. This is now also used as a primary identifier for `AccountStorageStandard`s. ## :books: Documentation Documentation was updated respectively. ## :white_check_mark: Testing Test cases were added for new functionality or fixed functionality. ## :pencil: Code of Conduct & Contributing Guidelines By submitting creating this pull request, you agree to follow our [Code of Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md): - [x] I agree to follow the [Code of Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md). --------- Co-authored-by: Paul Schmiedmayer <PSchmiedmayer@users.noreply.github.com> --- Sources/SpeziAccount/Account.swift | 38 ++++- .../SpeziAccount/AccountConfiguration.swift | 43 ++--- Sources/SpeziAccount/AccountHeader.swift | 61 ++++++- Sources/SpeziAccount/AccountOverview.swift | 4 +- .../AccountService/AccountService.swift | 7 +- .../Configuration/RequiredAccountKeys.swift | 12 +- .../Configuration/SupportedAccountKeys.swift | 2 +- .../Wrapper/StandardBacked.swift | 4 + .../StorageStandardBackedAccountService.swift | 39 +++-- Sources/SpeziAccount/AccountSetup.swift | 86 +++++++--- .../SpeziAccount/AccountStorageStandard.swift | 6 +- .../AccountValue/AccountKey+Views.swift | 13 +- .../AccountValue/AccountKey.swift | 7 + .../AccountValue/AccountKeyCollection.swift | 4 + .../Collections/AccountDetails.swift | 4 + .../Collections/ModifiedAccountDetails.swift | 13 ++ .../Collections/SignupDetails.swift | 4 +- ...rror.swift => AccountOperationError.swift} | 19 ++- .../AccountValueConfiguration.swift | 43 +++++ .../AccountValue/Keys/AccountIdKey.swift | 73 +++++++++ .../AccountValue/Keys/IsNewUserKey.swift | 21 +++ .../AccountValue/Keys/PasswordKey.swift | 4 +- .../AccountValue/Keys/UserIdKey.swift | 25 ++- .../Environment/AccountRequiredKey.swift | 29 ++++ .../MockUserIdPasswordAccountService.swift | 6 + .../Model/AdditionalRecordId.swift | 14 +- .../Model/Validation/ValidationEngines.swift | 16 +- .../Resources/de.lproj/Localizable.strings | 31 ++-- .../Resources/en.lproj/Localizable.strings | 27 ++- .../Creating your own Account Service.md | 6 +- .../Setup Guides/Initial Setup.md | 5 +- .../AccountOverviewFormViewModel.swift | 62 +++++-- .../AccountRequiredModifier.swift | 69 ++++++++ ...VerifyRequiredAccountDetailsModifier.swift | 78 +++++++++ ...tRow.swift => AccountKeyOverviewRow.swift} | 8 +- .../AccountOverviewHeader.swift | 5 +- .../AccountOverviewSections.swift | 42 +++-- .../Views/AccountOverview/NameOverview.swift | 50 +++--- .../AccountOverview/SecurityOverview.swift | 33 ++-- .../AccountOverview/SingleEditView.swift | 2 +- .../DefaultAccountSetupHeader.swift | 6 +- .../AccountSetup/FollowUpInfoSheet.swift | 155 ++++++++++++++++++ .../AccountSetup/SignupSectionsView.swift | 73 +++++++++ .../Views/AccountSummaryBox.swift | 5 +- .../DataEntry/GeneralizedDataEntryView.swift | 14 +- Sources/SpeziAccount/Views/SignupForm.swift | 88 +++++----- .../AccountTests/AccountTestsView.swift | 72 +++++--- .../TestAccountConfiguration.swift | 6 +- .../AccountTests/TestAccountService.swift | 17 +- .../TestApp/AccountTests/UserStorage.swift | 11 +- Tests/UITests/TestApp/Features.swift | 7 + Tests/UITests/TestApp/TestApp.entitlements | 10 ++ .../TestApp/TestApp.entitlements.license | 5 + Tests/UITests/TestApp/TestAppDelegate.swift | 17 +- .../TestAppUITests/AccountOverviewTests.swift | 30 +++- .../TestAppUITests/AccountSetupTests.swift | 39 ++++- .../TestAppUITests/Utils/SignupView.swift | 8 +- .../TestAppUITests/Utils/TestApp.swift | 10 +- .../UITests/UITests.xcodeproj/project.pbxproj | 4 + .../xcshareddata/xcschemes/TestApp.xcscheme | 8 + 60 files changed, 1260 insertions(+), 340 deletions(-) rename Sources/SpeziAccount/AccountValue/Configuration/{AccountValueConfigurationError.swift => AccountOperationError.swift} (67%) create mode 100644 Sources/SpeziAccount/AccountValue/Keys/AccountIdKey.swift create mode 100644 Sources/SpeziAccount/AccountValue/Keys/IsNewUserKey.swift create mode 100644 Sources/SpeziAccount/Environment/AccountRequiredKey.swift create mode 100644 Sources/SpeziAccount/ViewModifier/AccountRequiredModifier.swift create mode 100644 Sources/SpeziAccount/ViewModifier/VerifyRequiredAccountDetailsModifier.swift rename Sources/SpeziAccount/Views/AccountOverview/{AccountKeyEditRow.swift => AccountKeyOverviewRow.swift} (92%) create mode 100644 Sources/SpeziAccount/Views/AccountSetup/FollowUpInfoSheet.swift create mode 100644 Sources/SpeziAccount/Views/AccountSetup/SignupSectionsView.swift create mode 100644 Tests/UITests/TestApp/TestApp.entitlements create mode 100644 Tests/UITests/TestApp/TestApp.entitlements.license diff --git a/Sources/SpeziAccount/Account.swift b/Sources/SpeziAccount/Account.swift index 1141214a..3889e51f 100644 --- a/Sources/SpeziAccount/Account.swift +++ b/Sources/SpeziAccount/Account.swift @@ -51,7 +51,7 @@ import SwiftUI /// ### Managing Account state /// This section provides an overview on how to manage and manipulate the current user account as an ``AccountService``. /// -/// - ``supplyUserDetails(_:)`` +/// - ``supplyUserDetails(_:isNewUser:)`` /// - ``removeUserDetails()`` /// /// ### Initializers for your Preview Provider @@ -89,7 +89,7 @@ public class Account: ObservableObject, Sendable { /// /// - Note: This array also contains ``IdentityProvider``s that need to be treated differently due to differing /// ``AccountSetupViewStyle`` implementations (see ``IdentityProviderViewStyle``). - let registeredAccountServices: [any AccountService] + public let registeredAccountServices: [any AccountService] /// Initialize a new `Account` object by providing all properties individually. /// - Parameters: @@ -112,8 +112,8 @@ public class Account: ObservableObject, Sendable { logger.warning( """ Your AccountConfiguration doesn't have the \\.userId (aka. UserIdKey) configured. \ - A primary and unique user identifier is expected with most SpeziAccount components and \ - will result in those components breaking. + A primary, user-visible identifier is recommended with most SpeziAccount components for \ + an optimal user experience. Ignore this warning if you know what you are doing. """ ) } @@ -183,10 +183,30 @@ public class Account: ObservableObject, Sendable { /// This method is called by the ``AccountService`` every time the state of the user account changes. /// Either if the went from no logged in user to having a logged in user, or if the details of the user account changed. /// - /// - Parameter details: The ``AccountDetails`` of the currently logged in user account. - public func supplyUserDetails(_ details: AccountDetails) async throws { + /// - Parameters: + /// - details: The ``AccountDetails`` of the currently logged in user account. + /// - isNewUser: An optional flag that indicates if the provided account details are for a new user registration. + /// If this flag is set to `true`, the ``AccountSetup`` view will render a additional information sheet not only for + /// ``AccountKeyRequirement/required``, but also for ``AccountKeyRequirement/collected`` account values. + /// This is primarily helpful for identity providers. You might not want to set this flag + /// if you using the builtin ``SignupForm``! + public func supplyUserDetails(_ details: AccountDetails, isNewUser: Bool = false) async throws { + precondition( + details.contains(AccountIdKey.self), + """ + The provided `AccountDetails` do not have the \\.accountId (aka. AccountIdKey) set. \ + A primary, unique and stable user identifier is expected with most SpeziAccount components and \ + will result in those components breaking. + """ + ) + var details = details + if isNewUser { + details.patchIsNewUser(true) + } + + // Account details will always get built by the respective Account Service. Therefore, we need to patch it // if they are wrapped into a StandardBacked one such that the `AccountDetails` carry the correct reference. for service in registeredAccountServices { @@ -206,7 +226,9 @@ public class Account: ObservableObject, Sendable { if let standardBacked = details.accountService as? any StandardBacked, let storageStandard = standardBacked.standard as? any AccountStorageStandard { - let recordId = AdditionalRecordId(serviceId: standardBacked.backedId, userId: details.userId) + let recordId = AdditionalRecordId(serviceId: standardBacked.backedId, accountId: details.accountId) + + try await standardBacked.preUserDetailsSupply(recordId: recordId) let unsupportedKeys = details.accountService.configuration .unsupportedAccountKeys(basedOn: configuration) @@ -232,7 +254,7 @@ public class Account: ObservableObject, Sendable { if let details, let standardBacked = details.accountService as? any StandardBacked, let storageStandard = standardBacked.standard as? any AccountStorageStandard { - let recordId = AdditionalRecordId(serviceId: standardBacked.backedId, userId: details.userId) + let recordId = AdditionalRecordId(serviceId: standardBacked.backedId, accountId: details.accountId) await storageStandard.clear(recordId) } diff --git a/Sources/SpeziAccount/AccountConfiguration.swift b/Sources/SpeziAccount/AccountConfiguration.swift index 6c3cc6f6..c7229bbc 100644 --- a/Sources/SpeziAccount/AccountConfiguration.swift +++ b/Sources/SpeziAccount/AccountConfiguration.swift @@ -77,9 +77,6 @@ public final class AccountConfiguration: Component, ObservableObjectProvider { public func configure() { // assemble the final array of account services let accountServices = (providedAccountServices + self.accountServices).map { service in - // verify that the configuration matches what is expected by the account service - verifyAccountServiceRequirements(of: service) - // Verify account service can store all configured account keys. // If applicable, wraps the service into an StandardBackedAccountService let service = verifyConfigurationRequirements(against: service) @@ -99,36 +96,22 @@ public final class AccountConfiguration: Component, ObservableObjectProvider { self.account?.injectWeakAccount(into: standard) } - private func verifyAccountServiceRequirements(of service: any AccountService) { - let requiredValues = service.configuration.requiredAccountKeys - - // A collection of AccountKey.Type which aren't configured by the user or not configured to be required - // but the Account Service requires them. - let mismatchedKeys: [any AccountKeyWithDescription] = requiredValues.filter { keyWithDescription in - let key = keyWithDescription.key - let configuration = configuredAccountKeys[key] - return configuration == nil - || (key.isRequired && configuration?.requirement != .required) - } - - guard !mismatchedKeys.isEmpty else { - return - } - - // Note: AccountKeyWithDescription has a nice `debugDescription` that pretty prints the KeyPath property name - preconditionFailure( - """ - You configured the AccountService \(service) which requires the following account values to be configured: \ - \(mismatchedKeys.description). - - Please modify your `AccountServiceConfiguration` to have these account values configured. - """ - ) - } - private func verifyConfigurationRequirements(against service: any AccountService) -> any AccountService { logger.debug("Checking \(service.description) against the configured account keys.") + // if account service states exact supported keys, AccountIdKey must be one of them + if case let .exactly(keys) = service.configuration.supportedAccountKeys { + precondition( + keys.contains(AccountIdKey.self), + """ + The account service \(type(of: service)) doesn't have the \\.accountId (aka. AccountIdKey) configured \ + as an supported key. \ + A primary, unique and stable user identifier is expected with most SpeziAccount components and \ + will result in those components breaking. + """ + ) + } + // collect all values that cannot be handled by the account service let unmappedAccountKeys: [any AccountKeyConfiguration] = service.configuration .unsupportedAccountKeys(basedOn: configuredAccountKeys) diff --git a/Sources/SpeziAccount/AccountHeader.swift b/Sources/SpeziAccount/AccountHeader.swift index b5d20b28..262dfa93 100644 --- a/Sources/SpeziAccount/AccountHeader.swift +++ b/Sources/SpeziAccount/AccountHeader.swift @@ -10,7 +10,7 @@ import SpeziViews import SwiftUI -/// A summary view for ``SpeziAccountOverview`` that can be used as a Button to link to ``SpeziAccountOverview``. +/// A account summary view that can be used to link to the ``AccountOverview``. /// /// Below is a short code example on how to use the `AccountHeader` view. /// @@ -37,7 +37,8 @@ public struct AccountHeader: View { public enum Defaults { /// Default caption. @_documentation(visibility: internal) - public static let caption = LocalizedStringResource("ACCOUNT_HEADER_CAPTION", bundle: .atURL(from: .module)) // swiftlint:disable:this attributes + public static let caption = LocalizedStringResource("ACCOUNT_HEADER_CAPTION", bundle: .atURL(from: .module)) + // swiftlint:disable:previous attributes } @EnvironmentObject private var account: Account @@ -47,12 +48,23 @@ public struct AccountHeader: View { let accountDetails = account.details HStack { - UserProfileView(name: accountDetails?.name ?? PersonNameComponents(givenName: "Placeholder", familyName: "Placeholder")) - .frame(height: 60) - .redacted(reason: account.details == nil ? .placeholder : []) - .accessibilityHidden(true) + if let accountDetails, + let name = accountDetails.name { + UserProfileView(name: name) + .frame(height: 60) + .accessibilityHidden(true) + } else { + Image(systemName: "person.crop.circle.fill") + .resizable() + .frame(width: 60, height: 60) + .foregroundColor(Color(.systemGray3)) + .accessibilityHidden(true) + } + VStack(alignment: .leading) { - Text(accountDetails?.name?.formatted() ?? "Placeholder") + let nameTitle = accountDetails?.name?.formatted(.name(style: .long)) ?? accountDetails?.userId ?? "Placeholder" + + Text(nameTitle) .font(.title2) .fontWeight(.semibold) .redacted(reason: account.details == nil ? .placeholder : []) @@ -89,7 +101,7 @@ public struct AccountHeader: View { let details = AccountDetails.Builder() .set(\.userId, value: "andi.bauer@tum.de") .set(\.name, value: PersonNameComponents(givenName: "Andreas", familyName: "Bauer")) - + return NavigationStack { Form { Section { @@ -103,4 +115,37 @@ public struct AccountHeader: View { } .environmentObject(Account(building: details, active: MockUserIdPasswordAccountService())) } + +#Preview { + let details = AccountDetails.Builder() + .set(\.userId, value: "andi.bauer@tum.de") + + return NavigationStack { + Form { + Section { + NavigationLink { + AccountOverview() + } label: { + AccountHeader() + } + } + } + } + .environmentObject(Account(building: details, active: MockUserIdPasswordAccountService())) +} + +#Preview { + NavigationStack { + Form { + Section { + NavigationLink { + AccountOverview() + } label: { + AccountHeader() + } + } + } + } + .environmentObject(Account(MockUserIdPasswordAccountService())) +} #endif diff --git a/Sources/SpeziAccount/AccountOverview.swift b/Sources/SpeziAccount/AccountOverview.swift index a71749b2..8ec97738 100644 --- a/Sources/SpeziAccount/AccountOverview.swift +++ b/Sources/SpeziAccount/AccountOverview.swift @@ -10,7 +10,7 @@ import SpeziViews import SwiftUI -/// The essential ``SpeziAccount`` view to view and modify the active account details. +/// The essential `SpeziAccount` view to view and modify the active account details. /// /// This provides an overview of the current account details. Further, it allows the user to modify their /// account values. @@ -52,7 +52,7 @@ import SwiftUI /// } /// ``` /// -/// - Note: The ``init(isEditing:)`` initializer allows to pass an optional `Bool` Binding to retrieve the +/// - Note: The ``init(isEditing:additionalSections:)`` initializer allows to pass an optional `Bool` Binding to retrieve the /// current edit mode of the view. This can be helpful to, e.g., render a custom `Close` Button if the /// view is not editing when presenting the AccountOverview in a sheet. public struct AccountOverview<AdditionalSections: View>: View { diff --git a/Sources/SpeziAccount/AccountService/AccountService.swift b/Sources/SpeziAccount/AccountService/AccountService.swift index b188dd22..cb10197f 100644 --- a/Sources/SpeziAccount/AccountService/AccountService.swift +++ b/Sources/SpeziAccount/AccountService/AccountService.swift @@ -19,8 +19,9 @@ import SwiftUI /// You may improve the user experience or rely on user interface defaults if you adopt protocols like /// ``EmbeddableAccountService`` or ``UserIdPasswordAccountService``. /// -/// - Note: `SpeziAccount` provides the generalized ``UserIdKey`` unique user identifier that can be customized -/// using the ``UserIdConfiguration``. +/// - Important: Every user account is expected to have a primary and unique user identifier. +/// SpeziAccount requires a stable and internal ``AccountIdKey`` unique user identifier and offers +/// a user visible ``UserIdKey`` which can be customized using the ``UserIdConfiguration``. /// /// You can learn more about creating an account service at: <doc:Creating-your-own-Account-Service>. /// @@ -52,7 +53,7 @@ public protocol AccountService: AnyObject, Hashable, CustomStringConvertible, Se /// Create a new user account for the provided ``SignupDetails``. /// - /// - Note: You must call ``Account/supplyUserDetails(_:)`` eventually once the user context was established after this call. + /// - Note: You must call ``Account/supplyUserDetails(_:isNewUser:)`` eventually once the user context was established after this call. /// - Parameter signupDetails: The signup details /// - Throws: Throw an `Error` type conforming to `LocalizedError` if the signup operation was unsuccessful, /// inorder to present a localized description to the user. diff --git a/Sources/SpeziAccount/AccountService/Configuration/RequiredAccountKeys.swift b/Sources/SpeziAccount/AccountService/Configuration/RequiredAccountKeys.swift index ceea4bfc..729d8435 100644 --- a/Sources/SpeziAccount/AccountService/Configuration/RequiredAccountKeys.swift +++ b/Sources/SpeziAccount/AccountService/Configuration/RequiredAccountKeys.swift @@ -9,18 +9,16 @@ import Spezi -/// The collection of ``AccountKey``s that are required to use the associated ``AccountService``. +/// The collection of ``AccountKey``s that are required when using the associated ``AccountService``. /// /// A ``AccountService`` may set this configuration to communicate that a certain set of ``AccountKey``s are -/// required to be configured in the ``AccountValueConfiguration`` provided in the ``AccountConfiguration`` in -/// order to user the account service. -/// -/// Upon startup, `SpeziAccount` automatically verifies that the user-configured account values match the expectation -/// set by the ``AccountService`` through this configuration option. +/// required to use the given account service. For example, a password-based account service defines the password +/// to be required using this configuration as the account value is only required in the context of using this specific +/// account service. /// /// Access the configuration via the ``AccountServiceConfiguration/requiredAccountKeys``. /// -/// Below is an example on how to provide this option. +/// Below is an example configuration for a userid-password-based account service. /// /// ```swift /// let configuration = AccountServiceConfiguration(/* ... */) { diff --git a/Sources/SpeziAccount/AccountService/Configuration/SupportedAccountKeys.swift b/Sources/SpeziAccount/AccountService/Configuration/SupportedAccountKeys.swift index 55aa4caa..8707a119 100644 --- a/Sources/SpeziAccount/AccountService/Configuration/SupportedAccountKeys.swift +++ b/Sources/SpeziAccount/AccountService/Configuration/SupportedAccountKeys.swift @@ -18,7 +18,7 @@ /// /// Access the configuration via the ``AccountServiceConfiguration/supportedAccountKeys``. /// -/// Belo is an example on how to provide a fixed set of supported account keys. +/// Below is an example on how to provide a fixed set of supported account keys. /// /// ```swift /// let supportedKeys = AccountKeyCollection { diff --git a/Sources/SpeziAccount/AccountService/Wrapper/StandardBacked.swift b/Sources/SpeziAccount/AccountService/Wrapper/StandardBacked.swift index 694c9c99..adfa12b4 100644 --- a/Sources/SpeziAccount/AccountService/Wrapper/StandardBacked.swift +++ b/Sources/SpeziAccount/AccountService/Wrapper/StandardBacked.swift @@ -20,6 +20,8 @@ protocol StandardBacked: AccountService { init(service: Service, standard: AccountStandard) func isBacking(service accountService: any AccountService) -> Bool + + func preUserDetailsSupply(recordId: AdditionalRecordId) async throws } @@ -39,6 +41,8 @@ extension StandardBacked { } return self.accountService.objId == service.objId } + + func preUserDetailsSupply(recordId: AdditionalRecordId) async throws {} } diff --git a/Sources/SpeziAccount/AccountService/Wrapper/StorageStandardBackedAccountService.swift b/Sources/SpeziAccount/AccountService/Wrapper/StorageStandardBackedAccountService.swift index 4bf35f82..ded588b0 100644 --- a/Sources/SpeziAccount/AccountService/Wrapper/StorageStandardBackedAccountService.swift +++ b/Sources/SpeziAccount/AccountService/Wrapper/StorageStandardBackedAccountService.swift @@ -18,6 +18,8 @@ actor StorageStandardBackedAccountService<Service: AccountService, Standard: Acc let standard: Standard let serviceSupportedKeys: AccountKeyCollection + private var pendingSignupDetails: SignupDetails? + nonisolated var configuration: AccountServiceConfiguration { accountService.configuration } @@ -27,9 +29,9 @@ actor StorageStandardBackedAccountService<Service: AccountService, Standard: Acc } - private var currentUserId: String? { + private var currentAccountId: String? { get async { - await account.details?.userId + await account.details?.accountId } } @@ -44,24 +46,28 @@ actor StorageStandardBackedAccountService<Service: AccountService, Standard: Acc self.serviceSupportedKeys = keys } - func signUp(signupDetails: SignupDetails) async throws { let details = splitDetails(from: signupDetails) - let recordId = AdditionalRecordId(serviceId: accountService.id, userId: signupDetails.userId) - - // call standard first, such that it will happen before any `supplyAccountDetails` calls made by the Account Service - try await standard.create(recordId, details.standard) + // save the details until the accountId is available. This will be in preUserDetailsSupply + self.pendingSignupDetails = details.standard try await accountService.signUp(signupDetails: details.service) } + func preUserDetailsSupply(recordId: AdditionalRecordId) async throws { + if let pendingSignupDetails { + try await standard.create(recordId, pendingSignupDetails) + self.pendingSignupDetails = nil + } + } + func updateAccountDetails(_ modifications: AccountModifications) async throws { - guard let userId = await currentUserId else { + guard let accountId = await currentAccountId else { return } - let modifiedDetails = splitDetails(from: modifications.modifiedDetails, copyUserId: true) + let modifiedDetails = splitDetails(from: modifications.modifiedDetails) let removedDetails = splitDetails(from: modifications.removedAccountDetails) let serviceModifications = AccountModifications( @@ -74,7 +80,7 @@ actor StorageStandardBackedAccountService<Service: AccountService, Standard: Acc removedAccountDetails: removedDetails.standard ) - let recordId = AdditionalRecordId(serviceId: accountService.id, userId: userId) + let recordId = AdditionalRecordId(serviceId: accountService.id, accountId: accountId) // first call the standard, such that it will happen before any `supplyAccountDetails` calls made by the Account Service try await standard.modify(recordId, standardModifications) @@ -83,29 +89,22 @@ actor StorageStandardBackedAccountService<Service: AccountService, Standard: Acc } func delete() async throws { - guard let userId = await currentUserId else { + guard let accountId = await currentAccountId else { return } - try await standard.delete(AdditionalRecordId(serviceId: accountService.id, userId: userId)) + try await standard.delete(AdditionalRecordId(serviceId: accountService.id, accountId: accountId)) try await accountService.delete() } private func splitDetails<Values: AccountValues>( - from details: Values, - copyUserId: Bool = false + from details: Values ) -> (service: Values, standard: Values) { let serviceBuilder = AccountValuesBuilder<Values>() let standardBuilder = AccountValuesBuilder<Values>(from: details) for element in serviceSupportedKeys { - if copyUserId && element.key == UserIdKey.self { - // ensure that in a `modify` call, the Standard gets notified about the updated userId as the primary - // identifier will change. - continue - } - // remove all service supported keys from the standard builder (which is a copy of `details` currently) standardBuilder.remove(element.key) } diff --git a/Sources/SpeziAccount/AccountSetup.swift b/Sources/SpeziAccount/AccountSetup.swift index 80444081..06b37b82 100644 --- a/Sources/SpeziAccount/AccountSetup.swift +++ b/Sources/SpeziAccount/AccountSetup.swift @@ -12,12 +12,13 @@ import SwiftUI public enum _AccountSetupState: EnvironmentKey { // swiftlint:disable:this type_name case generic case setupShown + case requiringAdditionalInfo(_ keys: [any AccountKey.Type]) case loadingExistingAccount public static var defaultValue: _AccountSetupState = .generic } -/// The essential ``SpeziAccount`` view to login into or signup for a user account. +/// The essential `SpeziAccount` view to login into or signup for a user account. /// /// This view handles account setup for a user. The user can choose from all configured ``AccountService`` and /// ``IdentityProvider`` instances to setup an active user account. They might create a new account with a given @@ -53,12 +54,14 @@ public enum _AccountSetupState: EnvironmentKey { // swiftlint:disable:this type_ /// } /// ``` public struct AccountSetup<Header: View, Continue: View>: View { + private let setupCompleteClosure: (AccountDetails) -> Void private let header: Header private let continueButton: Continue @EnvironmentObject var account: Account @State private var setupState: _AccountSetupState = .generic + @State private var followUpSheet = false private var services: [any AccountService] { account.registeredAccountServices @@ -82,14 +85,13 @@ public struct AccountSetup<Header: View, Continue: View>: View { Spacer() if let details = account.details { - if case .loadingExistingAccount = setupState { + switch setupState { + case let .requiringAdditionalInfo(keys): + followUpInformationSheet(details, requiredKeys: keys) + case .loadingExistingAccount: // We allow the outer view to navigate away upon signup, before we show the existing account view - ProgressView() - .task { - try? await Task.sleep(for: .seconds(2)) - setupState = .generic - } - } else { + existingAccountLoading + default: ExistingAccountView(details: details) { continueButton } @@ -110,9 +112,16 @@ public struct AccountSetup<Header: View, Continue: View>: View { .frame(maxWidth: .infinity) } } - .onReceive(account.$signedIn) { signedIn in - if signedIn, case .setupShown = setupState { - setupState = .loadingExistingAccount + .onReceive(account.$details) { details in + if let details, case .setupShown = setupState { + let missingKeys = account.configuration.missingRequiredKeys(for: details, includeCollected: details.isNewUser) + + if missingKeys.isEmpty { + setupState = .loadingExistingAccount + setupCompleteClosure(details) + } else { + setupState = .requiringAdditionalInfo(missingKeys) + } } } } @@ -136,26 +145,59 @@ public struct AccountSetup<Header: View, Continue: View>: View { } } + @ViewBuilder private var existingAccountLoading: some View { + ProgressView() + .task { + try? await Task.sleep(for: .seconds(2)) + setupState = .generic + } + } + + fileprivate init(state: _AccountSetupState) where Header == DefaultAccountSetupHeader, Continue == EmptyView { + self.setupCompleteClosure = { _ in } self.header = DefaultAccountSetupHeader() self.continueButton = EmptyView() self._setupState = State(initialValue: state) } + /// Create a new AccountSetup view. + /// - Parameters: + /// - setupComplete: The closure that is called once the account setup is considered to be completed. + /// Note that it may be the case, that there are global account details associated (see ``Account/details``) + /// but setup is not completed (e.g., after a login where additional info was required from the user). + /// - header: An optional Header view to be displayed. + /// - continue: A custom continue button you can place. This view will be rendered if the AccountSetup view is + /// displayed with an already associated account. public init( + setupComplete: @escaping (AccountDetails) -> Void = { _ in }, + @ViewBuilder header: () -> Header = { DefaultAccountSetupHeader() }, @ViewBuilder `continue`: () -> Continue = { EmptyView() } - ) where Header == DefaultAccountSetupHeader { - self.init(continue: `continue`, header: { DefaultAccountSetupHeader() }) - } - - - public init( - @ViewBuilder `continue`: () -> Continue = { EmptyView() }, - @ViewBuilder header: () -> Header ) { + self.setupCompleteClosure = setupComplete self.header = header() self.continueButton = `continue`() } + + + @ViewBuilder + private func followUpInformationSheet(_ details: AccountDetails, requiredKeys: [any AccountKey.Type]) -> some View { + ProgressView() + .sheet(isPresented: $followUpSheet) { + NavigationStack { + FollowUpInfoSheet(details: details, requiredKeys: requiredKeys) + } + } + .onAppear { + followUpSheet = true // we want full control through the setupState property + } + .onChange(of: followUpSheet) { newValue in + if !newValue { // follow up information was completed! + setupState = .loadingExistingAccount + setupCompleteClosure(details) + } + } + } } @@ -203,15 +245,15 @@ struct AccountView_Previews: PreviewProvider { AccountSetup(state: .setupShown) .environmentObject(Account(building: detailsBuilder, active: MockUserIdPasswordAccountService())) - AccountSetup { + AccountSetup(continue: { Button(action: { print("Continue") }, label: { Text("Continue") .frame(maxWidth: .infinity, minHeight: 38) }) - .buttonStyle(.borderedProminent) - } + .buttonStyle(.borderedProminent) + }) .environmentObject(Account(building: detailsBuilder, active: MockUserIdPasswordAccountService())) } } diff --git a/Sources/SpeziAccount/AccountStorageStandard.swift b/Sources/SpeziAccount/AccountStorageStandard.swift index ed653488..2fb4564b 100644 --- a/Sources/SpeziAccount/AccountStorageStandard.swift +++ b/Sources/SpeziAccount/AccountStorageStandard.swift @@ -16,7 +16,7 @@ import Spezi /// by your ``AccountService``, you may add an implementation of the `AccountStorageStandard` protocol to your App's `Standard`, /// inorder to handle storage and retrieval of these additional account values. /// -/// - Note: You can use the ``AccountReference`` property wrapper to get access to the global ``Account`` object if you need it to implement additional functionality. +/// - Note: You can use the ``Spezi/Standard/AccountReference`` property wrapper to get access to the global ``Account`` object if you need it to implement additional functionality. public protocol AccountStorageStandard: Standard { /// Create new associated account data. /// @@ -49,10 +49,6 @@ public protocol AccountStorageStandard: Standard { /// /// This call is used to apply all modifications of the Standard-managed account values. /// - /// - Important: The ``ModifiedAccountDetails`` the ``AccountModifications`` structure might - /// contain a change to the ``UserIdKey`` as well. This changes the primary ``AdditionalRecordId`` identifier - /// used in all calls to reference a certain record. You must update the primary identifier! - /// /// - Note: A call to this method might certainly be immediately followed by a call to ``load(_:_:)``. /// /// - Parameters: diff --git a/Sources/SpeziAccount/AccountValue/AccountKey+Views.swift b/Sources/SpeziAccount/AccountValue/AccountKey+Views.swift index 720b6d1b..a475060e 100644 --- a/Sources/SpeziAccount/AccountValue/AccountKey+Views.swift +++ b/Sources/SpeziAccount/AccountValue/AccountKey+Views.swift @@ -26,14 +26,11 @@ extension AccountKey { AnyView(GeneralizedDataEntryView<DataEntry, Values>(initialValue: initialValue.value)) } - static func dataEntryViewWithStoredValue<Values: AccountValues>( + static func dataEntryViewWithStoredValueOrInitial<Values: AccountValues>( details: AccountDetails, for values: Values.Type - ) -> AnyView? { - guard let value = details.storage.get(Self.self) else { - return nil - } - + ) -> AnyView { + let value = details.storage.get(Self.self) ?? initialValue.value return AnyView(GeneralizedDataEntryView<DataEntry, Values>(initialValue: value)) } @@ -55,4 +52,8 @@ extension AccountKey { return AnyView(DataDisplay(value)) } + + static func singleEditView(model: AccountOverviewFormViewModel, details accountDetails: AccountDetails) -> AnyView { + AnyView(SingleEditView<Self>(model: model, details: accountDetails)) + } } diff --git a/Sources/SpeziAccount/AccountValue/AccountKey.swift b/Sources/SpeziAccount/AccountValue/AccountKey.swift index 7a4cd171..95f8aa06 100644 --- a/Sources/SpeziAccount/AccountValue/AccountKey.swift +++ b/Sources/SpeziAccount/AccountValue/AccountKey.swift @@ -28,6 +28,7 @@ import XCTRuntimeAssertions /// ## Topics /// /// ### Builtin Account Keys +/// - ``AccountIdKey`` /// - ``UserIdKey`` /// - ``PasswordKey`` /// - ``PersonNameKey`` @@ -88,6 +89,12 @@ extension AccountKey { static var isRequired: Bool { self is any RequiredAccountKey.Type } + + /// A ``AccountKeyCategory/credentials`` key that is not meant to be modified in + /// the `SecurityOverview` section in the ``AccountOverview``. + static var isHiddenCredential: Bool { + self == AccountIdKey.self || self == UserIdKey.self + } } diff --git a/Sources/SpeziAccount/AccountValue/AccountKeyCollection.swift b/Sources/SpeziAccount/AccountValue/AccountKeyCollection.swift index 25fbc1b9..fd496e48 100644 --- a/Sources/SpeziAccount/AccountValue/AccountKeyCollection.swift +++ b/Sources/SpeziAccount/AccountValue/AccountKeyCollection.swift @@ -69,6 +69,10 @@ public struct AccountKeyCollection: Sendable, AcceptingAccountKeyVisitor { .map { $0.key } .acceptAll(&visitor) } + + public func contains<Key: AccountKey>(_ key: Key.Type) -> Bool { + elements.contains(where: { $0.key == key }) + } } diff --git a/Sources/SpeziAccount/AccountValue/Collections/AccountDetails.swift b/Sources/SpeziAccount/AccountValue/Collections/AccountDetails.swift index 1262d749..fc55de04 100644 --- a/Sources/SpeziAccount/AccountValue/Collections/AccountDetails.swift +++ b/Sources/SpeziAccount/AccountValue/Collections/AccountDetails.swift @@ -36,6 +36,10 @@ public struct AccountDetails: Sendable, AccountValues { mutating func patchAccountService(_ service: any AccountService) { storage[ActiveAccountServiceKey.self] = service } + + mutating func patchIsNewUser(_ isNewUser: Bool) { + storage[IsNewUserKey.self] = isNewUser + } } diff --git a/Sources/SpeziAccount/AccountValue/Collections/ModifiedAccountDetails.swift b/Sources/SpeziAccount/AccountValue/Collections/ModifiedAccountDetails.swift index f2de3db1..b1c7104c 100644 --- a/Sources/SpeziAccount/AccountValue/Collections/ModifiedAccountDetails.swift +++ b/Sources/SpeziAccount/AccountValue/Collections/ModifiedAccountDetails.swift @@ -19,3 +19,16 @@ public struct ModifiedAccountDetails: Sendable, AccountValues { self.storage = storage } } + + +extension AccountValuesBuilder where Values == ModifiedAccountDetails { + func build(validation: Bool) throws -> Values { + let details = self.build() + + if details.contains(AccountIdKey.self) { + throw AccountOperationError.accountIdChanged + } + + return details + } +} diff --git a/Sources/SpeziAccount/AccountValue/Collections/SignupDetails.swift b/Sources/SpeziAccount/AccountValue/Collections/SignupDetails.swift index 59540550..4d5f0b27 100644 --- a/Sources/SpeziAccount/AccountValue/Collections/SignupDetails.swift +++ b/Sources/SpeziAccount/AccountValue/Collections/SignupDetails.swift @@ -30,7 +30,7 @@ public struct SignupDetails: Sendable, AccountValues { let keyNames = missing.map { $0.keyPathDescription } LoggerKey.defaultValue.warning("\(keyNames) was/were required to be provided but wasn't/weren't provided!") - throw AccountValueConfigurationError.missingAccountValue(keyNames) + throw AccountOperationError.missingAccountValue(keyNames) } } } @@ -40,7 +40,7 @@ extension AccountValuesBuilder where Values == SignupDetails { /// Building new ``SignupDetails`` while checking it's contents against the user-defined ``AccountValueConfiguration``. /// - Parameter configuration: The configured provided by the user (see ``Account/configuration``). /// - Returns: The built ``SignupDetails``. - /// - Throws: Throws potential ``AccountValueConfigurationError`` if requirements are not fulfilled. + /// - Throws: Throws potential ``AccountOperationError`` if requirements are not fulfilled. public func build( checking configuration: AccountValueConfiguration ) throws -> Values { diff --git a/Sources/SpeziAccount/AccountValue/Configuration/AccountValueConfigurationError.swift b/Sources/SpeziAccount/AccountValue/Configuration/AccountOperationError.swift similarity index 67% rename from Sources/SpeziAccount/AccountValue/Configuration/AccountValueConfigurationError.swift rename to Sources/SpeziAccount/AccountValue/Configuration/AccountOperationError.swift index 2cac4e63..f328a0a9 100644 --- a/Sources/SpeziAccount/AccountValue/Configuration/AccountValueConfigurationError.swift +++ b/Sources/SpeziAccount/AccountValue/Configuration/AccountOperationError.swift @@ -9,14 +9,16 @@ import Foundation -/// An error that occurs due to restrictions or requirements of a ``AccountValueConfiguration``. -public enum AccountValueConfigurationError: LocalizedError { +/// An error that occurs due to restrictions or requirements (e.g., imposed by ``AccountValueConfiguration``). +public enum AccountOperationError: LocalizedError { /// A ``AccountKeyRequirement/required`` ``AccountKey`` that was not supplied by the signup view before /// being passed to the ``AccountService``. /// /// - Note: This is an error in the view logic due to missing user-input sanitization or simply the view /// forgot to supply the ``AccountKey`` when building the ``SignupDetails``. case missingAccountValue(_ keyNames: [String]) + /// The stable ``AccountIdKey`` was tried to be modified. + case accountIdChanged public var errorDescription: String? { @@ -35,21 +37,22 @@ public enum AccountValueConfigurationError: LocalizedError { private var errorDescriptionValue: String.LocalizationValue { switch self { case .missingAccountValue: - return "ACCOUNT_VALUES_MISSING_VALUE_DESCRIPTION" + return "ACCOUNT_ERROR_VALUES_MISSING_VALUE_DESCRIPTION" + case .accountIdChanged: + return "ACCOUNT_ERROR_ACCOUNT_ID_CHANGED_DESCRIPTION" } } private var failureReasonValue: String.LocalizationValue { switch self { case let .missingAccountValue(keyName): - return "ACCOUNT_VALUES_MISSING_VALUE_REASON \(keyName.joined(separator: ", "))" + return "ACCOUNT_ERROR_VALUES_MISSING_VALUE_REASON \(keyName.joined(separator: ", "))" + case .accountIdChanged: + return "ACCOUNT_ERROR_ACCOUNT_ID_CHANGED_REASON" } } private var recoverySuggestionValue: String.LocalizationValue { - switch self { - case .missingAccountValue: - return "ACCOUNT_VALUES_MISSING_VALUE_RECOVERY" - } + "ACCOUNT_ERROR_RECOVERY" } } diff --git a/Sources/SpeziAccount/AccountValue/Configuration/AccountValueConfiguration.swift b/Sources/SpeziAccount/AccountValue/Configuration/AccountValueConfiguration.swift index b4a3961b..6d18454c 100644 --- a/Sources/SpeziAccount/AccountValue/Configuration/AccountValueConfiguration.swift +++ b/Sources/SpeziAccount/AccountValue/Configuration/AccountValueConfiguration.swift @@ -32,6 +32,49 @@ public struct AccountValueConfiguration { } + func all(filteredBy filter: [AccountKeyRequirement]? = nil) -> [any AccountKey.Type] { + // swiftlint:disable:previous discouraged_optional_collection + + if let filter { + return self + .filter { configuration in + filter.contains(configuration.requirement) + } + .map { $0.key } + } else { + return configuration.values.map { $0.key } + } + } + + func allCategorized(filteredBy filter: [AccountKeyRequirement]? = nil) -> OrderedDictionary<AccountKeyCategory, [any AccountKey.Type]> { + // swiftlint:disable:previous discouraged_optional_collection + if let filter { + return self.reduce(into: [:]) { result, configuration in + guard filter.contains(configuration.requirement) else { + return + } + + result[configuration.key.category, default: []] += [configuration.key] + } + } else { + return self.reduce(into: [:]) { result, configuration in + result[configuration.key.category, default: []] += [configuration.key] + } + } + } + + func missingRequiredKeys(for details: AccountDetails, includeCollected: Bool = false) -> [any AccountKey.Type] { + let accountKeyIds = Set(details.keys.map { ObjectIdentifier($0) }) + + return self + .all(filteredBy: includeCollected ? [.required, .collected] : [.required]) + .filter { $0.category != .credentials } // don't collect credentials! + .filter { key in + !accountKeyIds.contains(ObjectIdentifier(key)) + } + } + + /// Retrieve the configuration for a given type-erased ``AccountKey``. /// - Parameter key: The account key to query. /// - Returns: The configuration for a given ``AccountKey`` if it exists. diff --git a/Sources/SpeziAccount/AccountValue/Keys/AccountIdKey.swift b/Sources/SpeziAccount/AccountValue/Keys/AccountIdKey.swift new file mode 100644 index 00000000..5acd989d --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/Keys/AccountIdKey.swift @@ -0,0 +1,73 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +/// The primary, unique, stable and typically internal identifier for an user account. +/// +/// The `accountId` is used to uniquely identify a given account at any point in time. +/// While the ``UserIdKey`` is typically the primary user-facing identifier and might change, the `accountId` is internal +/// and a stable account identifier (e.g., to associate stored data to the account). +/// +/// - Note: You should aim to use a string-based identifier, that doesn't contain any special characters to allow +/// for maximum compatibility with other components. +/// +/// ### Configuration +/// As a user you don't need to worry about manually configuring the `accountId`. As it is not user-facing, +/// you don't have to do anything. +/// +/// As an ``AccountService`` you are required to supply the `accountId` for every ``AccountDetails`` you provide +/// to ``Account/supplyUserDetails(_:isNewUser:)``. Further, if you supply a ``SupportedAccountKeys/exactly(_:)`` +/// configuration as part of your ``AccountServiceConfiguration``, make sure to include the `accountId` there as well. +public struct AccountIdKey: RequiredAccountKey { + public typealias Value = String + + public static let name = LocalizedStringResource("ACCOUNT_ID", bundle: .atURL(from: .module)) + + public static let category: AccountKeyCategory = .credentials +} + + +extension AccountKeys { + /// The accountId ``AccountIdKey`` + public var accountId: AccountIdKey.Type { + AccountIdKey.self + } +} + + +extension AccountValues { + /// Access the account id of a user (see ``AccountIdKey``). + public var accountId: String { + storage[AccountIdKey.self] + } +} + + +extension AccountIdKey { + public struct DataDisplay: DataDisplayView { + public typealias Key = AccountIdKey + + public var body: some View { + Text("The internal account identifier is not meant to be user facing!") + } + + public init(_ value: Value) {} + } + + public struct DataEntry: DataEntryView { + public typealias Key = AccountIdKey + + public var body: some View { + Text("The internal account identifier is meant to be generated!") + } + + public init(_ value: Binding<Key.Value>) {} + } +} diff --git a/Sources/SpeziAccount/AccountValue/Keys/IsNewUserKey.swift b/Sources/SpeziAccount/AccountValue/Keys/IsNewUserKey.swift new file mode 100644 index 00000000..f3f87e8a --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/Keys/IsNewUserKey.swift @@ -0,0 +1,21 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Spezi + + +struct IsNewUserKey: KnowledgeSource { + typealias Anchor = AccountAnchor + typealias Value = Bool +} + +extension AccountDetails { + var isNewUser: Bool { + storage[IsNewUserKey.self] ?? false + } +} diff --git a/Sources/SpeziAccount/AccountValue/Keys/PasswordKey.swift b/Sources/SpeziAccount/AccountValue/Keys/PasswordKey.swift index a077da35..3a413f7c 100644 --- a/Sources/SpeziAccount/AccountValue/Keys/PasswordKey.swift +++ b/Sources/SpeziAccount/AccountValue/Keys/PasswordKey.swift @@ -21,7 +21,7 @@ import SwiftUI /// ### Password UI /// /// - ``PasswordFieldType`` -public struct PasswordKey: RequiredAccountKey { +public struct PasswordKey: AccountKey { public typealias Value = String public static let name = LocalizedStringResource("UP_PASSWORD", bundle: .atURL(from: .module)) @@ -42,7 +42,7 @@ extension AccountKeys { extension SignupDetails { /// Access the password of a user in the ``SignupDetails``. - public var password: String { + public var password: String? { storage[PasswordKey.self] } } diff --git a/Sources/SpeziAccount/AccountValue/Keys/UserIdKey.swift b/Sources/SpeziAccount/AccountValue/Keys/UserIdKey.swift index c838721c..99ceb3d5 100644 --- a/Sources/SpeziAccount/AccountValue/Keys/UserIdKey.swift +++ b/Sources/SpeziAccount/AccountValue/Keys/UserIdKey.swift @@ -6,23 +6,38 @@ // SPDX-License-Identifier: MIT // +import Spezi import SwiftUI -/// A string-based, unique user identifier. +/// A string-based, user-facing, unique user identifier. /// -/// The `userId` is used to uniquely identify a given account. The value might carry -/// additional semantics. For example, the `userId` might, at the same time, be the primary email address -/// of the user. Such semantics can be controlled by the ``AccountService`` +/// The `userId` is used to uniquely identify a given account at a given point in time. +/// While the ``AccountIdKey`` is guaranteed to be stable, the `userId` might change over time. +/// But it will still be unique. +/// +/// - Note: If an ``AccountService`` doesn't provide a `userId`, it will fallback to return the ``AccountIdKey``. +/// +/// The value might carry additional semantics. For example, the `userId` might, at the same time, +/// be the primary email address of the user. Such semantics can be controlled by the ``AccountService`` /// using the ``UserIdType`` configuration. /// /// - Note: You may also refer to the ``EmailAddressKey`` to query the email address of an account. -public struct UserIdKey: RequiredAccountKey { +public struct UserIdKey: AccountKey, ComputedKnowledgeSource { + public typealias StoragePolicy = AlwaysCompute public typealias Value = String public static let name = LocalizedStringResource("USER_ID", bundle: .atURL(from: .module)) public static let category: AccountKeyCategory = .credentials + + public static func compute<Repository: SharedRepository<AccountAnchor>>(from repository: Repository) -> String { + if let value = repository.get(Self.self) { + return value // return the userId if there is one stored + } + + return repository[AccountIdKey.self] // otherwise return the primary account key + } } diff --git a/Sources/SpeziAccount/Environment/AccountRequiredKey.swift b/Sources/SpeziAccount/Environment/AccountRequiredKey.swift new file mode 100644 index 00000000..4ed653db --- /dev/null +++ b/Sources/SpeziAccount/Environment/AccountRequiredKey.swift @@ -0,0 +1,29 @@ +// +// This source file is part of the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +struct AccountRequiredKey: EnvironmentKey { + static let defaultValue = false +} + + +extension EnvironmentValues { + /// An environment variable that indicates if an account was configured to be required for the app. + /// + /// Fore more information have a look at ``SwiftUI/View/accountRequired(_:setupSheet:)``. + public var accountRequired: Bool { + get { + self[AccountRequiredKey.self] + } + set { + self[AccountRequiredKey.self] = newValue + } + } +} diff --git a/Sources/SpeziAccount/Mock/MockUserIdPasswordAccountService.swift b/Sources/SpeziAccount/Mock/MockUserIdPasswordAccountService.swift index bc25cdbd..1d74d21c 100644 --- a/Sources/SpeziAccount/Mock/MockUserIdPasswordAccountService.swift +++ b/Sources/SpeziAccount/Mock/MockUserIdPasswordAccountService.swift @@ -14,6 +14,7 @@ public actor MockUserIdPasswordAccountService: UserIdPasswordAccountService { @AccountReference private var account: Account public let configuration: AccountServiceConfiguration + private var userIdToAccountId: [String: UUID] = [:] /// Create a new userId- and password-based account service. @@ -34,6 +35,7 @@ public actor MockUserIdPasswordAccountService: UserIdPasswordAccountService { try await Task.sleep(for: .seconds(1)) let details = AccountDetails.Builder() + .set(\.accountId, value: userIdToAccountId[userId, default: UUID()].uuidString) .set(\.userId, value: userId) .set(\.name, value: PersonNameComponents(givenName: "Andreas", familyName: "Bauer")) .build(owner: self) @@ -44,7 +46,11 @@ public actor MockUserIdPasswordAccountService: UserIdPasswordAccountService { print("Mock Signup: \(signupDetails)") try await Task.sleep(for: .seconds(1)) + let id = UUID() + userIdToAccountId[signupDetails.userId] = id + let details = AccountDetails.Builder(from: signupDetails) + .set(\.accountId, value: id.uuidString) .remove(\.password) .build(owner: self) try await account.supplyUserDetails(details) diff --git a/Sources/SpeziAccount/Model/AdditionalRecordId.swift b/Sources/SpeziAccount/Model/AdditionalRecordId.swift index 61274489..e5f00e93 100644 --- a/Sources/SpeziAccount/Model/AdditionalRecordId.swift +++ b/Sources/SpeziAccount/Model/AdditionalRecordId.swift @@ -9,18 +9,18 @@ /// A stable identifier used by ``AccountStorageStandard`` instances to identity a set of additionally stored records. /// -/// The identifier is built by combining a stable ``AccountService`` identifier and the primary userId (see ``UserIdKey``). +/// The identifier is built by combining a stable ``AccountService`` identifier and the primary accountID (see ``AccountIdKey``). /// Using both, additional data records of a user can be uniquely identified across ``AccountService`` implementations. public struct AdditionalRecordId: CustomStringConvertible, Hashable, Identifiable { /// A stable ``AccountService`` identifier. See ``AccountService/id-83c6c``. public let accountServiceId: String - /// The primary user identifier. See ``UserIdKey``. - public let userId: String + /// The primary user identifier. See ``AccountIdKey``. + public let accountId: String /// String representation of the record identifier. public var description: String { - accountServiceId + "-" + userId + accountServiceId + "-" + accountId } /// The identifier. @@ -29,9 +29,9 @@ public struct AdditionalRecordId: CustomStringConvertible, Hashable, Identifiabl } - init(serviceId accountServiceId: String, userId: String) { + init(serviceId accountServiceId: String, accountId: String) { self.accountServiceId = accountServiceId - self.userId = userId + self.accountId = accountId } @@ -42,6 +42,6 @@ public struct AdditionalRecordId: CustomStringConvertible, Hashable, Identifiabl public func hash(into hasher: inout Hasher) { accountServiceId.hash(into: &hasher) - userId.hash(into: &hasher) + accountId.hash(into: &hasher) } } diff --git a/Sources/SpeziAccount/Model/Validation/ValidationEngines.swift b/Sources/SpeziAccount/Model/Validation/ValidationEngines.swift index ac51c5f3..8d973c46 100644 --- a/Sources/SpeziAccount/Model/Validation/ValidationEngines.swift +++ b/Sources/SpeziAccount/Model/Validation/ValidationEngines.swift @@ -171,6 +171,8 @@ public class ValidationEngines<FieldIdentifier: Hashable>: ObservableObject { // deliberately not @Published, registered methods should not trigger an UI update private var storage: OrderedDictionary<UUID, RegisteredEngine<FieldIdentifier>> + private var hooks: [String: () -> Void] = [:] + /// Reports input validity of all registered ``ValidationEngine``s. @MainActor public var allInputValid: Bool { storage.values @@ -230,9 +232,21 @@ public class ValidationEngines<FieldIdentifier: Hashable>: ObservableObject { } } + func register(id: String, hook: @escaping () -> Void) { + hooks[id] = hook + } + + func remove(hook: String) { + hooks[hook] = nil + } + @MainActor private func collectFailedResults() -> [FailedResult<FieldIdentifier>] { - storage.values.compactMap { engine in + for hook in hooks.values { + hook() + } + + return storage.values.compactMap { engine in engine() } } diff --git a/Sources/SpeziAccount/Resources/de.lproj/Localizable.strings b/Sources/SpeziAccount/Resources/de.lproj/Localizable.strings index a107688a..16cd9fb1 100644 --- a/Sources/SpeziAccount/Resources/de.lproj/Localizable.strings +++ b/Sources/SpeziAccount/Resources/de.lproj/Localizable.strings @@ -20,9 +20,11 @@ "ACCOUNT_WELCOME_SIGNED_IN_SUBTITLE" = "Du bist bereits mit dem folgenden Benutzerkonto angemeldet. Du kannst dein Benutzerkonto ändern, indem du dich abmeldest."; // MARK: - AccountValues -"ACCOUNT_VALUES_MISSING_VALUE_DESCRIPTION" = "Fehlende Werte im Benutzerkonto"; -"ACCOUNT_VALUES_MISSING_VALUE_REASON %@" = "Die folgenden Werte wurden nicht im Benutzerkonto gesetzt: %@"; -"ACCOUNT_VALUES_MISSING_VALUE_RECOVERY" = "Melde dieses Problem dem Entwickler des Account Services."; +"ACCOUNT_ERROR_VALUES_MISSING_VALUE_DESCRIPTION" = "Fehlende Werte im Benutzerkonto"; +"ACCOUNT_ERROR_VALUES_MISSING_VALUE_REASON %@" = "Die folgenden Werte wurden nicht im Benutzerkonto gesetzt: %@."; +"ACCOUNT_ERROR_ACCOUNT_ID_CHANGED_DESCRIPTION" = "Anfrage Fehlgeschlagen"; +"ACCOUNT_ERROR_ACCOUNT_ID_CHANGED_REASON" = "Deine primäre Benutzerkennung kann nicht geändert werden da diese dauerhaft vergeben wird!"; +"ACCOUNT_ERROR_RECOVERY" = "Melde dieses Problem dem Entwickler des Account Services."; // MARK: - UserId and Password "UP_PASSWORD" = "Passwort"; @@ -40,7 +42,8 @@ "UP_LOGIN_FAILED_DEFAULT_ERROR" = "Anmeldung fehlgeschlagen!"; // MARK: - UserId and Password (Signup) -"UP_SIGNUP_INSTRUCTIONS" = "Please fill out the details below to create a new account."; +"UP_SIGNUP_HEADER" = "Erstelle dein Benutzerkonto"; +"UP_SIGNUP_INSTRUCTIONS" = "Bitte fülle die folgenden Informationen aus, um ein neues Benutzerkonto zu erstellen."; "UP_CREDENTIALS" = "Zugangsdaten"; "UP_NAME" = "Name"; "UP_CONTACT_DETAILS" = "Kontaktdaten"; @@ -54,6 +57,11 @@ "UAP_RESET_PASSWORD_PROCESS_SUCCESSFUL_LABEL" = "Ein Link zum Zurücksetzen das Passworts wurde versandt."; "UAP_RESET_PASSWORD_FAILED_DEFAULT_ERROR" = "Passwort Zurücksetzen fehlgeschlagen!"; +// MARK: - Follow-Up Information +"FOLLOW_UP_INFORMATION_TITLE" = "Account Vervollständigen"; +"FOLLOW_UP_INFORMATION_INSTRUCTIONS" = "Bitte fülle die folgenden Informationen aus, um dein Benutzerkonto zu vervollständigen."; +"FOLLOW_UP_INFORMATION_COMPLETE" = "Fertig"; + // MARK: - Account Summary "UP_LOGOUT_FAILED_DEFAULT_ERROR" = "Abmelden fehlgeschlagen!"; @@ -66,7 +74,7 @@ "CANCEL" = "Abbrechen"; "ACCOUNT_OVERVIEW_EDIT_DEFAULT_ERROR" = "Speichern deiner Änderung fehlgeschlagen!"; "REMOVE_DEFAULT_ERROR"= "Löschen fehlgeschlagen!"; -"SECURITY" = "Sicherheit"; +"SIGN_IN_AND_SECURITY" = "Anmeldung & Sicherheit"; "VALUE_ADD %@" = "%@ Hinzufügen"; "CHANGE_PASSWORD" = "Passwort Ändern"; @@ -75,9 +83,11 @@ // MARK - Confirmation Dialogs "CONFIRMATION_DISCARD_CHANGES_TITLE" = "Willst du alle Änderungen verwerfen?"; -"CONFIRMATION_DISCARD_INPUT_TITLE" = "Willst du deine Eingaben verwerfen?"; "CONFIRMATION_DISCARD_CHANGES" = "Änderungen Verwerfen"; +"CONFIRMATION_DISCARD_INPUT_TITLE" = "Willst du deine Eingaben verwerfen?"; "CONFIRMATION_DISCARD_INPUT" = "Eingaben Verwerfen"; +"CONFIRMATION_DISCARD_ADDITIONAL_INFO_TITLE" = "Diese Kontoinformationen sind erfoderlich. Wenn du abbrichst, wirst du automatisch abgemeldet!"; +"CONFIRMATION_DISCARD_ADDITIONAL_INFO" = "Abbrechen und Abmelden"; "CONFIRMATION_KEEP_EDITING"= "Weiter Bearbeiten"; "CONFIRMATION_LOGOUT" = "Willst du dich wirklich abmelden?"; "CONFIRMATION_REMOVAL" = "Willst du wirklich dein Benutzerkonto löschen?"; @@ -93,15 +103,14 @@ "VALIDATION_RULE_GIVEN_NAME_EMPTY" = "Dein Vorname kann nicht leer sein!"; "VALIDATION_RULE_FAMILY_NAME_EMPTY" = "Dein Nachname kann nicht leer sein!"; +// MARK: - Account Id +"ACCOUNT_ID" = "Benutzerkennung"; + // MARK: - User Id -"USER_ID" = "Benutzerkennung"; +"USER_ID" = "Benutzername"; "USER_ID_EMAIL" = "E-Mail Adresse"; "USER_ID_USERNAME" = "Benutzername"; -// MARK: - General Views -"LOGIN" = "Anmelden"; -"SIGN_UP" = "Benutzerkonto Erstellen"; - // MARK: - Person Name "NAME" = "Name"; "UAP_SIGNUP_GIVEN_NAME_TITLE" = "Vorname"; diff --git a/Sources/SpeziAccount/Resources/en.lproj/Localizable.strings b/Sources/SpeziAccount/Resources/en.lproj/Localizable.strings index 161d19f8..336894c3 100644 --- a/Sources/SpeziAccount/Resources/en.lproj/Localizable.strings +++ b/Sources/SpeziAccount/Resources/en.lproj/Localizable.strings @@ -19,10 +19,12 @@ "ACCOUNT_WELCOME_SUBTITLE" = "Please login to your account. Or create a new one if you don't have one already."; "ACCOUNT_WELCOME_SIGNED_IN_SUBTITLE" = "You are already logged in with the account shown below. Continue or change your account by logging out."; -// MARK: - AccountValues -"ACCOUNT_VALUES_MISSING_VALUE_DESCRIPTION" = "Missing Account Values"; -"ACCOUNT_VALUES_MISSING_VALUE_REASON %@" = "The following required account values were not supplied: %@"; -"ACCOUNT_VALUES_MISSING_VALUE_RECOVERY" = "Raise an issue with the developer of the Account Service."; +// MARK: - Account Operation Error +"ACCOUNT_ERROR_VALUES_MISSING_VALUE_DESCRIPTION" = "Missing Account Values"; +"ACCOUNT_ERROR_VALUES_MISSING_VALUE_REASON %@" = "The following required account values were not supplied: %@."; +"ACCOUNT_ERROR_ACCOUNT_ID_CHANGED_DESCRIPTION" = "Failed Account Operation"; +"ACCOUNT_ERROR_ACCOUNT_ID_CHANGED_REASON" = "You primary account identifier cannot be changed as it is required to be stable!"; +"ACCOUNT_ERROR_RECOVERY" = "Raise an issue with the developer of the Account Service."; // MARK: - UserId and Password "UP_PASSWORD" = "Password"; @@ -40,7 +42,8 @@ "UP_LOGIN_FAILED_DEFAULT_ERROR" = "Could not login!"; // MARK: - UserId and Password (Signup) -"UP_SIGNUP_INSTRUCTIONS" = "Please fill out the details below to create a new account."; +"UP_SIGNUP_HEADER" = "Create a new Account"; +"UP_SIGNUP_INSTRUCTIONS" = "Please fill out the details below to create your new account."; "UP_CREDENTIALS" = "Credentials"; "UP_NAME" = "Name"; "UP_CONTACT_DETAILS" = "Contact Details"; @@ -54,6 +57,11 @@ "UAP_RESET_PASSWORD_PROCESS_SUCCESSFUL_LABEL" = "Sent out a link to reset the password."; "UAP_RESET_PASSWORD_FAILED_DEFAULT_ERROR" = "Could not reset the password!"; +// MARK: - Follow-Up Information +"FOLLOW_UP_INFORMATION_TITLE" = "Finish Account Setup"; +"FOLLOW_UP_INFORMATION_INSTRUCTIONS" = "Please fill out the details below to complete your account setup."; +"FOLLOW_UP_INFORMATION_COMPLETE" = "Complete"; + // MARK: - Account Summary "UP_LOGOUT_FAILED_DEFAULT_ERROR" = "Could not logout!"; @@ -66,7 +74,7 @@ "CANCEL" = "Cancel"; "ACCOUNT_OVERVIEW_EDIT_DEFAULT_ERROR" = "Could not save account details!"; "REMOVE_DEFAULT_ERROR"= "Could not remove account!"; -"SECURITY" = "Security"; +"SIGN_IN_AND_SECURITY" = "Sign-In & Security"; "VALUE_ADD %@" = "Add %@"; "CHANGE_PASSWORD" = "Change Password"; @@ -75,9 +83,11 @@ // MARK - Confirmation Dialogs "CONFIRMATION_DISCARD_CHANGES_TITLE" = "Are you sure you want to discard your changes?"; -"CONFIRMATION_DISCARD_INPUT_TITLE" = "Are you sure you want to discard your input?"; "CONFIRMATION_DISCARD_CHANGES" = "Discard Changes"; +"CONFIRMATION_DISCARD_INPUT_TITLE" = "Are you sure you want to discard your input?"; "CONFIRMATION_DISCARD_INPUT" = "Discard Input"; +"CONFIRMATION_DISCARD_ADDITIONAL_INFO_TITLE" = "This account information is required. If you abort, you will automatically be signed out!"; +"CONFIRMATION_DISCARD_ADDITIONAL_INFO" = "Cancel and Logout"; "CONFIRMATION_KEEP_EDITING"= "Keep Editing"; "CONFIRMATION_LOGOUT" = "Are you sure you want to logout?"; "CONFIRMATION_REMOVAL" = "Are you sure you want to delete your account?"; @@ -93,6 +103,9 @@ "VALIDATION_RULE_GIVEN_NAME_EMPTY" = "The first name field cannot be empty!"; "VALIDATION_RULE_FAMILY_NAME_EMPTY" = "The last name field cannot be empty!"; +// MARK: - Account Id +"ACCOUNT_ID" = "Account Identifier"; + // MARK: - User Id "USER_ID" = "User Identifier"; "USER_ID_EMAIL" = "E-Mail Address"; diff --git a/Sources/SpeziAccount/SpeziAccount.docc/Account Services/Creating your own Account Service.md b/Sources/SpeziAccount/SpeziAccount.docc/Account Services/Creating your own Account Service.md index 31542915..8fd9e09f 100644 --- a/Sources/SpeziAccount/SpeziAccount.docc/Account Services/Creating your own Account Service.md +++ b/Sources/SpeziAccount/SpeziAccount.docc/Account Services/Creating your own Account Service.md @@ -49,11 +49,11 @@ Apart from implementing the ``AccountService`` protocol, an account service is r of any changes of the user state (e.g., user information updated remotely). To do so, you can use the ``AccountService/AccountReference`` property wrapper to get access to the ``Account`` context. -You can then use the ``Account/supplyUserDetails(_:)`` and ``Account/removeUserDetails()`` methods +You can then use the ``Account/supplyUserDetails(_:isNewUser:)`` and ``Account/removeUserDetails()`` methods to update the account state. Below is a short code example that implements a basic remote session expiration handler. -> Note: You will always need to call the ``Account/supplyUserDetails(_:)`` and ``Account/removeUserDetails()`` methods manually, +> Note: You will always need to call the ``Account/supplyUserDetails(_:isNewUser:)`` and ``Account/removeUserDetails()`` methods manually, even if the change in user state is caused by a local operation like ``AccountService/signUp(signupDetails:)`` or ``AccountService/logout()``. ```swift @@ -126,5 +126,5 @@ class MyComponent: Component { ### Managing Account Details -- ``Account/supplyUserDetails(_:)`` +- ``Account/supplyUserDetails(_:isNewUser:)`` - ``Account/removeUserDetails()`` diff --git a/Sources/SpeziAccount/SpeziAccount.docc/Setup Guides/Initial Setup.md b/Sources/SpeziAccount/SpeziAccount.docc/Setup Guides/Initial Setup.md index a5eb67c5..6e3be2b9 100644 --- a/Sources/SpeziAccount/SpeziAccount.docc/Setup Guides/Initial Setup.md +++ b/Sources/SpeziAccount/SpeziAccount.docc/Setup Guides/Initial Setup.md @@ -92,7 +92,7 @@ struct MyView: View { } ``` -> Note: You can also customize the header text using the ``AccountSetup/init(continue:header:)`` initializer. +> Note: You can also customize the header text using the ``AccountSetup/init(setupComplete:header:continue:)`` initializer. ### Account Overview @@ -116,12 +116,13 @@ struct MyView: View { - ``AccountValueConfiguration`` - ``AccountKeyRequirement`` - ``AccountKeyConfiguration`` -- ``AccountValueConfigurationError`` +- ``AccountOperationError`` ### Views - ``AccountSetup`` - ``AccountOverview`` +- ``AccountHeader`` ### Reacting to Events diff --git a/Sources/SpeziAccount/ViewModel/AccountOverviewFormViewModel.swift b/Sources/SpeziAccount/ViewModel/AccountOverviewFormViewModel.swift index 5e032c10..32f46af8 100644 --- a/Sources/SpeziAccount/ViewModel/AccountOverviewFormViewModel.swift +++ b/Sources/SpeziAccount/ViewModel/AccountOverviewFormViewModel.swift @@ -49,9 +49,7 @@ class AccountOverviewFormViewModel: ObservableObject { init(account: Account) { - self.categorizedAccountKeys = account.configuration.reduce(into: [:]) { result, configuration in - result[configuration.key.category, default: []] += [configuration.key] - } + self.categorizedAccountKeys = account.configuration.allCategorized() // We forward the objectWillChange publisher. Our `hasUnsavedChanges` is affected by changes to the builder. // Otherwise, changes to the object wouldn't be important. @@ -65,13 +63,22 @@ class AccountOverviewFormViewModel: ObservableObject { func accountKeys(by category: AccountKeyCategory, using details: AccountDetails) -> [any AccountKey.Type] { - categorizedAccountKeys[category, default: []] + var result = categorizedAccountKeys[category, default: []] .sorted(using: AccountOverviewValuesComparator(details: details, added: addedAccountKeys, removed: removedAccountKeys)) + + for describedKey in details.accountService.configuration.requiredAccountKeys + where describedKey.key.category == category { + result.append(describedKey.key) + } + + return result } - func editableAccountKeys(details accountDetails: AccountDetails) -> OrderedDictionary<AccountKeyCategory, [any AccountKey.Type]> { - let results = categorizedAccountKeys.filter { category, _ in - category != .credentials && category != .name + private func baseSortedAccountKeys(details accountDetails: AccountDetails) -> OrderedDictionary<AccountKeyCategory, [any AccountKey.Type]> { + var results = categorizedAccountKeys + + for describedKey in accountDetails.accountService.configuration.requiredAccountKeys { + results[describedKey.key.category, default: []] += [describedKey.key] } // We want to establish the following order: @@ -84,6 +91,27 @@ class AccountOverviewFormViewModel: ObservableObject { } } + func editableAccountKeys(details accountDetails: AccountDetails) -> OrderedDictionary<AccountKeyCategory, [any AccountKey.Type]> { + baseSortedAccountKeys(details: accountDetails).filter { category, _ in + category != .credentials && category != .name + } + } + + func namesOverviewKeys(details accountDetails: AccountDetails) -> [any AccountKey.Type] { + var result = baseSortedAccountKeys(details: accountDetails) + .filter { category, _ in + category == .credentials || category == .name + } + + if result[.credentials]?.contains(where: { $0 == UserIdKey.self }) == true { + result[.credentials] = [UserIdKey.self] // ensure userId is the only credential we display + } + + return result.reduce(into: []) { result, tuple in + result.append(contentsOf: tuple.value) + } + } + func addAccountDetail(for value: any AccountKey.Type) { guard !addedAccountKeys.contains(value) else { return @@ -144,8 +172,8 @@ class AccountOverviewFormViewModel: ObservableObject { let removedDetailsBuilder = RemovedAccountDetails.Builder() removedDetailsBuilder.merging(with: removedAccountKeys.keys, from: details) - let modifications = AccountModifications( - modifiedDetails: modifiedDetailsBuilder.build(), + let modifications = try AccountModifications( + modifiedDetails: modifiedDetailsBuilder.build(validation: true), removedAccountDetails: removedDetailsBuilder.build() ) @@ -177,16 +205,14 @@ class AccountOverviewFormViewModel: ObservableObject { return userId } - func accountSecurityLabel(_ configuration: AccountValueConfiguration) -> Text { - let security = Text("SECURITY", bundle: .module) - - if configuration[PasswordKey.self] != nil { - return Text("UP_PASSWORD", bundle: .module) - + Text(" & ") - + security - } + func displaysSignInSecurityDetails(_ details: AccountDetails) -> Bool { + accountKeys(by: .credentials, using: details) + .contains(where: { !$0.isHiddenCredential }) + } - return security + func displaysNameDetails() -> Bool { + categorizedAccountKeys[.credentials]?.contains(where: { $0 == UserIdKey.self }) == true + || categorizedAccountKeys[.name]?.isEmpty != true } diff --git a/Sources/SpeziAccount/ViewModifier/AccountRequiredModifier.swift b/Sources/SpeziAccount/ViewModifier/AccountRequiredModifier.swift new file mode 100644 index 00000000..86c8fc03 --- /dev/null +++ b/Sources/SpeziAccount/ViewModifier/AccountRequiredModifier.swift @@ -0,0 +1,69 @@ +// +// This source file is part of the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +struct AccountRequiredModifier<SetupSheet: View>: ViewModifier { + private let required: Bool + private let setupSheet: SetupSheet + + @EnvironmentObject private var account: Account + + @State private var presentingSheet = false + + + init(required: Bool, @ViewBuilder setupSheet: () -> SetupSheet) { + self.required = required + self.setupSheet = setupSheet() + } + + + func body(content: Content) -> some View { + if required { + content + .onChange(of: [account.signedIn, presentingSheet]) { _ in + if !account.signedIn && !presentingSheet { + presentingSheet = true + } + } + .task { + try? await Task.sleep(for: .milliseconds(500)) + if !account.signedIn { + presentingSheet = true + } + } + .sheet(isPresented: $presentingSheet) { + setupSheet + .interactiveDismissDisabled(true) + } + .environment(\.accountRequired, true) + } else { + content + } + } +} + + +extension View { + /// Use this modifier to ensure that there is always an associated account in your app. + /// + /// If account requirement is set, this modifier will automatically pop open an account setup sheet if + /// it is detected that the associated user account was removed. + /// + /// - Note: This modifier injects the ``SwiftUI/EnvironmentValues + /// + /// - Parameters: + /// - required: The flag indicating if an account is required at all times. + /// - setupSheet: The view that is presented if no account was detected. You may present the ``AccountSetup`` view here. + /// This view is directly used with the standard SwiftUI sheet modifier. + /// - Returns: The modified view. + public func accountRequired<SetupSheet: View>(_ required: Bool, @ViewBuilder setupSheet: () -> SetupSheet) -> some View { + modifier(AccountRequiredModifier(required: required, setupSheet: setupSheet)) + } +} diff --git a/Sources/SpeziAccount/ViewModifier/VerifyRequiredAccountDetailsModifier.swift b/Sources/SpeziAccount/ViewModifier/VerifyRequiredAccountDetailsModifier.swift new file mode 100644 index 00000000..dad206b9 --- /dev/null +++ b/Sources/SpeziAccount/ViewModifier/VerifyRequiredAccountDetailsModifier.swift @@ -0,0 +1,78 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +private struct FollowUpSession: Identifiable { + var id: String { + details.userId + } + + let details: AccountDetails + let requiredKeys: [any AccountKey.Type] +} + + +struct VerifyRequiredAccountDetailsModifier: ViewModifier { + private let verify: Bool + + @EnvironmentObject private var account: Account + + @SceneStorage("edu.stanford.spezi-account.startup-account-check") private var verifiedAccount = false + @State private var followUpSession: FollowUpSession? + + + init(verify: Bool) { + self.verify = verify + } + + + func body(content: Content) -> some View { + if verify { + content + .sheet(item: $followUpSession) { session in + FollowUpInfoSheet(details: session.details, requiredKeys: session.requiredKeys) + } + .task { + guard !verifiedAccount else { + return + } + + try? await Task.sleep(for: .milliseconds(500)) + verifiedAccount = true + + if let details = account.details { + let missingKeys = account.configuration.missingRequiredKeys(for: details) + + if !missingKeys.isEmpty { + followUpSession = FollowUpSession(details: details, requiredKeys: missingKeys) + } + } + } + } else { + content + } + } +} + + +extension View { + /// Used this modifier to ensure that all user accounts in your app are up to date with your + /// SpeziAccount configuration. + /// + /// Withing your ``AccountConfiguration`` you define your app-global ``AccountValueConfiguration`` that defines + /// what ``AccountKey`` are required and collected at signup. You can use this modifier to collect additional information + /// form existing users, should your configuration of **required** account keys change between one of your releases. + /// + /// - Parameter verify: Flag indicating if this verification check is turned on. + /// - Returns: The modified view. + public func verifyRequiredAccountDetails(_ verify: Bool = true) -> some View { + modifier(VerifyRequiredAccountDetailsModifier(verify: verify)) + } +} diff --git a/Sources/SpeziAccount/Views/AccountOverview/AccountKeyEditRow.swift b/Sources/SpeziAccount/Views/AccountOverview/AccountKeyOverviewRow.swift similarity index 92% rename from Sources/SpeziAccount/Views/AccountOverview/AccountKeyEditRow.swift rename to Sources/SpeziAccount/Views/AccountOverview/AccountKeyOverviewRow.swift index 6bc04fff..08af9793 100644 --- a/Sources/SpeziAccount/Views/AccountOverview/AccountKeyEditRow.swift +++ b/Sources/SpeziAccount/Views/AccountOverview/AccountKeyOverviewRow.swift @@ -9,7 +9,7 @@ import SwiftUI -struct AccountKeyEditRow: View { +struct AccountKeyOverviewRow: View { private let accountDetails: AccountDetails private let accountKey: any AccountKey.Type @@ -26,8 +26,8 @@ struct AccountKeyEditRow: View { Group { if let view = accountKey.dataEntryViewFromBuilder(builder: model.modifiedDetailsBuilder, for: ModifiedAccountDetails.self) { view - } else if let view = accountKey.dataEntryViewWithStoredValue(details: accountDetails, for: ModifiedAccountDetails.self) { - view + } else { + accountKey.dataEntryViewWithStoredValueOrInitial(details: accountDetails, for: ModifiedAccountDetails.self) } } .environment(\.accountViewType, .overview(mode: .existing)) @@ -91,7 +91,7 @@ struct AccountKeyEditRow_Previews: PreviewProvider { static var previews: some View { if let details = account.details { - AccountKeyEditRow(details: details, for: GenderIdentityKey.self, model: model) + AccountKeyOverviewRow(details: details, for: GenderIdentityKey.self, model: model) .injectEnvironmentObjects(service: details.accountService, model: model, focusState: $focusedDataEntry) } } diff --git a/Sources/SpeziAccount/Views/AccountOverview/AccountOverviewHeader.swift b/Sources/SpeziAccount/Views/AccountOverview/AccountOverviewHeader.swift index af57398c..d6340751 100644 --- a/Sources/SpeziAccount/Views/AccountOverview/AccountOverviewHeader.swift +++ b/Sources/SpeziAccount/Views/AccountOverview/AccountOverviewHeader.swift @@ -21,11 +21,10 @@ struct AccountOverviewHeader: View { UserProfileView(name: profileViewName) .frame(height: 90) } else { - Image(systemName: "person.circle.fill") + Image(systemName: "person.crop.circle.fill") .resizable() .frame(width: 40, height: 40) - .symbolRenderingMode(.hierarchical) - .foregroundColor(Color(.systemGray)) + .foregroundColor(Color(.systemGray3)) .accessibilityHidden(true) } } diff --git a/Sources/SpeziAccount/Views/AccountOverview/AccountOverviewSections.swift b/Sources/SpeziAccount/Views/AccountOverview/AccountOverviewSections.swift index e6268893..6ae83af0 100644 --- a/Sources/SpeziAccount/Views/AccountOverview/AccountOverviewSections.swift +++ b/Sources/SpeziAccount/Views/AccountOverview/AccountOverviewSections.swift @@ -122,19 +122,8 @@ struct AccountOverviewSections<AdditionalSections: View>: View { // sync the edit mode with the outer view isEditing = newValue } - - Section { - NavigationLink { - NameOverview(model: model, details: accountDetails) - } label: { - model.accountIdentifierLabel(configuration: account.configuration, userIdType: accountDetails.userIdType) - } - NavigationLink { - SecurityOverview(model: model, details: accountDetails) - } label: { - model.accountSecurityLabel(account.configuration) - } - } + + defaultSections sectionsView .injectEnvironmentObjects(service: service, model: model, focusState: $focusedDataEntry) @@ -163,6 +152,31 @@ struct AccountOverviewSections<AdditionalSections: View>: View { .frame(maxWidth: .infinity, alignment: .center) } } + + @ViewBuilder private var defaultSections: some View { + let displayName = model.displaysNameDetails() + let displaySecurity = model.displaysSignInSecurityDetails(accountDetails) + + if displayName || displaySecurity { + Section { + if displayName { + NavigationLink { + NameOverview(model: model, details: accountDetails) + } label: { + model.accountIdentifierLabel(configuration: account.configuration, userIdType: accountDetails.userIdType) + } + } + + if displaySecurity { + NavigationLink { + SecurityOverview(model: model, details: accountDetails) + } label: { + Text("SIGN_IN_AND_SECURITY", bundle: .module) + } + } + } + } + } @ViewBuilder private var sectionsView: some View { ForEach(model.editableAccountKeys(details: accountDetails).elements, id: \.key) { category, accountKeys in @@ -174,7 +188,7 @@ struct AccountOverviewSections<AdditionalSections: View>: View { } ForEach(forEachWrappers, id: \.id) { wrapper in - AccountKeyEditRow(details: accountDetails, for: wrapper.accountKey, model: model) + AccountKeyOverviewRow(details: accountDetails, for: wrapper.accountKey, model: model) } .onDelete { indexSet in model.deleteAccountKeys(at: indexSet, in: accountKeys) diff --git a/Sources/SpeziAccount/Views/AccountOverview/NameOverview.swift b/Sources/SpeziAccount/Views/AccountOverview/NameOverview.swift index c41962cd..2ef57ec5 100644 --- a/Sources/SpeziAccount/Views/AccountOverview/NameOverview.swift +++ b/Sources/SpeziAccount/Views/AccountOverview/NameOverview.swift @@ -19,34 +19,32 @@ struct NameOverview: View { var body: some View { Form { - Section { - NavigationLink { - SingleEditView<UserIdKey>(model: model, details: accountDetails) - } label: { - UserIdKey.dataDisplayViewWithCurrentStoredValue(from: accountDetails) - } - } - - Section { - NavigationLink { - SingleEditView<PersonNameKey>(model: model, details: accountDetails) - } label: { - if let name = accountDetails.name { - PersonNameKey.DataDisplay(name) - } else { - HStack { - Text(PersonNameKey.name) - .accessibilityHidden(true) - Spacer() - Text("VALUE_ADD \(PersonNameKey.name)", bundle: .module) - .foregroundColor(.secondary) - } + let forEachWrappers = model.namesOverviewKeys(details: accountDetails) + .map { ForEachAccountKeyWrapper($0) } + + ForEach(forEachWrappers, id: \.id) { wrapper in + Section { + NavigationLink { + wrapper.accountKey.singleEditView(model: model, details: accountDetails) + } label: { + if let view = wrapper.accountKey.dataDisplayViewWithCurrentStoredValue(from: accountDetails) { + view + } else { + HStack { + Text(wrapper.accountKey.name) + .accessibilityHidden(true) + Spacer() + Text("VALUE_ADD \(wrapper.accountKey.name)", bundle: .module) + .foregroundColor(.secondary) + } .accessibilityElement(children: .combine) + } + } + } header: { + if wrapper.accountKey == PersonNameKey.self, + let title = PersonNameKey.category.categoryTitle { + Text(title) } - } - } header: { - if let title = PersonNameKey.category.categoryTitle { - Text(title) } } } diff --git a/Sources/SpeziAccount/Views/AccountOverview/SecurityOverview.swift b/Sources/SpeziAccount/Views/AccountOverview/SecurityOverview.swift index 060996c8..d44a222d 100644 --- a/Sources/SpeziAccount/Views/AccountOverview/SecurityOverview.swift +++ b/Sources/SpeziAccount/Views/AccountOverview/SecurityOverview.swift @@ -29,32 +29,37 @@ struct SecurityOverview: View { var body: some View { Form { - Button("Change Password", action: { - presentingPasswordChangeSheet = true - }) - .sheet(isPresented: $presentingPasswordChangeSheet) { - PasswordChangeSheet(model: model, details: accountDetails) - } - - // we place every account key of the `.credentials` section except the userId and password below + // we place every account key of the `.credentials` section except the userId let forEachWrappers = model.accountKeys(by: .credentials, using: accountDetails) - .filter { $0 != UserIdKey.self && $0 != PasswordKey.self } + .filter { !$0.isHiddenCredential } .map { ForEachAccountKeyWrapper($0) } ForEach(forEachWrappers, id: \.id) { wrapper in Section { - // This view currently doesn't implement an EditMode. Current intention is that the - // DataDisplay view of `.credentials` account values just build toggles or NavigationLinks - // to manage and change the respective account value. - AccountKeyEditRow(details: accountDetails, for: wrapper.accountKey, model: model) + if wrapper.accountKey == PasswordKey.self { + // we have a special case for the PasswordKey, as we currently don't expose the capabilities required to the subviews! + Button(action: { + presentingPasswordChangeSheet = true + }) { + Text("CHANGE_PASSWORD", bundle: .module) + } + .sheet(isPresented: $presentingPasswordChangeSheet) { + PasswordChangeSheet(model: model, details: accountDetails) + } + } else { + // This view currently doesn't implement an EditMode. Current intention is that the + // DataDisplay view of `.credentials` account values just build toggles or NavigationLinks + // to manage and change the respective account value. + AccountKeyOverviewRow(details: accountDetails, for: wrapper.accountKey, model: model) + } } } .injectEnvironmentObjects(service: service, model: model, focusState: $focusedDataEntry) .environment(\.defaultErrorDescription, model.defaultErrorDescription) } .viewStateAlert(state: $viewState) - .navigationTitle(model.accountSecurityLabel(account.configuration)) + .navigationTitle(Text("SIGN_IN_AND_SECURITY", bundle: .module)) .navigationBarTitleDisplayMode(.inline) .onDisappear { model.resetModelState() diff --git a/Sources/SpeziAccount/Views/AccountOverview/SingleEditView.swift b/Sources/SpeziAccount/Views/AccountOverview/SingleEditView.swift index 092d3cfc..0656542f 100644 --- a/Sources/SpeziAccount/Views/AccountOverview/SingleEditView.swift +++ b/Sources/SpeziAccount/Views/AccountOverview/SingleEditView.swift @@ -35,7 +35,7 @@ struct SingleEditView<Key: AccountKey>: View { var body: some View { Form { VStack { - Key.dataEntryViewWithStoredValue(details: accountDetails, for: ModifiedAccountDetails.self) + Key.dataEntryViewWithStoredValueOrInitial(details: accountDetails, for: ModifiedAccountDetails.self) } } .navigationTitle(Text(Key.self == UserIdKey.self ? accountDetails.userIdType.localizedStringResource : Key.name)) diff --git a/Sources/SpeziAccount/Views/AccountSetup/DefaultAccountSetupHeader.swift b/Sources/SpeziAccount/Views/AccountSetup/DefaultAccountSetupHeader.swift index 5dbd0437..3fa89054 100644 --- a/Sources/SpeziAccount/Views/AccountSetup/DefaultAccountSetupHeader.swift +++ b/Sources/SpeziAccount/Views/AccountSetup/DefaultAccountSetupHeader.swift @@ -27,10 +27,10 @@ public struct DefaultAccountSetupHeader: View { .padding(.top, 30) Group { - if !account.signedIn || setupState == .loadingExistingAccount { - Text("ACCOUNT_WELCOME_SUBTITLE", bundle: .module) - } else { + if account.signedIn, case .generic = setupState { Text("ACCOUNT_WELCOME_SIGNED_IN_SUBTITLE", bundle: .module) + } else { + Text("ACCOUNT_WELCOME_SUBTITLE", bundle: .module) } } .multilineTextAlignment(.center) diff --git a/Sources/SpeziAccount/Views/AccountSetup/FollowUpInfoSheet.swift b/Sources/SpeziAccount/Views/AccountSetup/FollowUpInfoSheet.swift new file mode 100644 index 00000000..2cd6cf24 --- /dev/null +++ b/Sources/SpeziAccount/Views/AccountSetup/FollowUpInfoSheet.swift @@ -0,0 +1,155 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import OrderedCollections +import SpeziViews +import SwiftUI + + +struct FollowUpInfoSheet: View { + private let accountDetails: AccountDetails + private let accountKeyByCategory: OrderedDictionary<AccountKeyCategory, [any AccountKey.Type]> + + private var service: any AccountService { + accountDetails.accountService + } + + @Environment(\.logger) private var logger + @Environment(\.dismiss) private var dismiss + + @EnvironmentObject private var account: Account + + @StateObject private var detailsBuilder = ModifiedAccountDetails.Builder() + @StateObject private var validationEngines = ValidationEngines<String>() + + @State private var viewState: ViewState = .idle + @FocusState private var focusedDataEntry: String? + + @State private var presentingCancellationConfirmation = false + + + var body: some View { + form + .interactiveDismissDisabled(true) + .viewStateAlert(state: $viewState) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(action: { + presentingCancellationConfirmation = true + }) { + Text("CANCEL", bundle: .module) + } + } + } + .confirmationDialog( + Text("CONFIRMATION_DISCARD_ADDITIONAL_INFO_TITLE", bundle: .module), + isPresented: $presentingCancellationConfirmation, + titleVisibility: .visible + ) { + Button(role: .destructive, action: { + dismiss() + }) { + Text("CONFIRMATION_DISCARD_ADDITIONAL_INFO", bundle: .module) + } + Button(role: .cancel, action: {}) { + Text("CONFIRMATION_KEEP_EDITING", bundle: .module) + } + } + } + + @ViewBuilder private var form: some View { + Form { + VStack { + Image(systemName: "person.crop.rectangle.badge.plus") + .foregroundColor(.accentColor) + .symbolRenderingMode(.multicolor) + .font(.custom("XXL", size: 50, relativeTo: .title)) + .accessibilityHidden(true) + Text("FOLLOW_UP_INFORMATION_TITLE", bundle: .module) + .accessibilityAddTraits(.isHeader) + .font(.title) + .bold() + .padding(.bottom, 4) + Text("FOLLOW_UP_INFORMATION_INSTRUCTIONS", bundle: .module) + .padding([.leading, .trailing], 25) + } + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .listRowBackground(Color.clear) + .padding(.top, -3) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + + SignupSectionsView(for: ModifiedAccountDetails.self, service: service, sections: accountKeyByCategory) + .environment(\.accountServiceConfiguration, service.configuration) + .environment(\.accountViewType, .signup) + .environmentObject(detailsBuilder) + .environmentObject(validationEngines) + .environmentObject(FocusStateObject(focusedField: $focusedDataEntry)) + + AsyncButton(state: $viewState, action: completeButtonAction) { + Text("FOLLOW_UP_INFORMATION_COMPLETE", bundle: .module) + .padding(16) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding() + .padding(-36) + .listRowBackground(Color.clear) + .disabled(!validationEngines.allInputValid) + } + .environment(\.defaultErrorDescription, .init("ACCOUNT_OVERVIEW_EDIT_DEFAULT_ERROR", bundle: .atURL(from: .module))) + } + + + init(details: AccountDetails, requiredKeys: [any AccountKey.Type]) { + self.accountDetails = details + self.accountKeyByCategory = requiredKeys.reduce(into: [:]) { result, key in + result[key.category, default: []] += [key] + } + } + + + private func completeButtonAction() async throws { + guard validationEngines.validateSubviews(focusState: $focusedDataEntry) else { + logger.debug("Failed to save updated account information. Validation failed!") + return + } + + focusedDataEntry = nil + + let modifiedDetails = try detailsBuilder.build(validation: true) + let removedDetails = RemovedAccountDetails.Builder().build() + + let modifications = AccountModifications(modifiedDetails: modifiedDetails, removedAccountDetails: removedDetails) + + logger.debug("Finished additional account setup. Saving \(detailsBuilder.count) changes!") + + try await service.updateAccountDetails(modifications) + + dismiss() + } +} + + +#if DEBUG +struct FollowUpInfoSheet_Previews: PreviewProvider { + static let details = AccountDetails.Builder() + .set(\.userId, value: "lelandstanford@stanford.edu") + + static let account = Account(building: details, active: MockUserIdPasswordAccountService()) + + static var previews: some View { + NavigationStack { + if let details = account.details { + FollowUpInfoSheet(details: details, requiredKeys: [PersonNameKey.self]) + } + } + .environmentObject(account) + } +} +#endif diff --git a/Sources/SpeziAccount/Views/AccountSetup/SignupSectionsView.swift b/Sources/SpeziAccount/Views/AccountSetup/SignupSectionsView.swift new file mode 100644 index 00000000..9d16e9f7 --- /dev/null +++ b/Sources/SpeziAccount/Views/AccountSetup/SignupSectionsView.swift @@ -0,0 +1,73 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import OrderedCollections +import SwiftUI + + +/// This views renders the sections for the signup or signup-like views. +/// +/// The view and it's subviews typically expect the following environment objects: +/// - The global ``Account`` object +/// - The internal `FocusStateObject` to pass down a `FocusState` (for the PersonNameKey implementation). +/// - An instance of ``AccountValuesBuilder`` according to the generic ``AccountValues`` type. +/// - An ``ValidationEngines`` object. +/// - The ``SwiftUI/EnvironmentValues/accountServiceConfiguration`` environment variable. +/// - The ``SwiftUI/EnvironmentValues/accountViewType`` environment variable. +struct SignupSectionsView<Storage: AccountValues>: View { + private let service: any AccountService + private let sections: OrderedDictionary<AccountKeyCategory, [any AccountKey.Type]> + private let storageType: Storage.Type + + @EnvironmentObject private var account: Account + + var body: some View { + // OrderedDictionary `elements` conforms to RandomAccessCollection so we can directly use it + ForEach(sections.elements, id: \.key) { category, accountKeys in + Section { + // the array doesn't change, so its fine to rely on the indices as identifiers + ForEach(accountKeys.indices, id: \.self) { index in + VStack { + accountKeys[index].emptyDataEntryView(for: storageType) + } + } + } header: { + if let title = category.categoryTitle { + Text(title) + } + } footer: { + if category == .credentials && account.configuration[PasswordKey.self] != nil { + PasswordValidationRuleFooter(configuration: service.configuration) + } + } + } + } + + init(for storageType: Storage.Type, service: any AccountService, sections: OrderedDictionary<AccountKeyCategory, [any AccountKey.Type]>) { + self.service = service + self.sections = sections + self.storageType = storageType + } +} + + +#if DEBUG +struct SignupSectionsView_Previews: PreviewProvider { + private static let service = MockUserIdPasswordAccountService() + + static var previews: some View { + Form { + SignupSectionsView(for: SignupDetails.self, service: service, sections: [ + .credentials: [UserIdKey.self, PasswordKey.self], + .name: [PersonNameKey.self] + ]) + } + .environmentObject(Account(service)) + } +} +#endif diff --git a/Sources/SpeziAccount/Views/AccountSummaryBox.swift b/Sources/SpeziAccount/Views/AccountSummaryBox.swift index fd9920cd..78e89af7 100644 --- a/Sources/SpeziAccount/Views/AccountSummaryBox.swift +++ b/Sources/SpeziAccount/Views/AccountSummaryBox.swift @@ -21,11 +21,10 @@ public struct AccountSummaryBox: View { UserProfileView(name: profileViewName) .frame(height: 40) } else { - Image(systemName: "person.circle.fill") + Image(systemName: "person.crop.circle.fill") .resizable() .frame(width: 40, height: 40) - .symbolRenderingMode(.hierarchical) - .foregroundColor(Color(.systemGray)) + .foregroundColor(Color(.systemGray3)) .accessibilityHidden(true) } } diff --git a/Sources/SpeziAccount/Views/DataEntry/GeneralizedDataEntryView.swift b/Sources/SpeziAccount/Views/DataEntry/GeneralizedDataEntryView.swift index a1940705..f2b2c257 100644 --- a/Sources/SpeziAccount/Views/DataEntry/GeneralizedDataEntryView.swift +++ b/Sources/SpeziAccount/Views/DataEntry/GeneralizedDataEntryView.swift @@ -26,10 +26,15 @@ private protocol GeneralizedStringEntryView { /// ``DataEntryView/Key``, a ``SwiftUI/View/managedValidation(input:for:rules:)-5gj5g`` modifier is automatically injected. One can easily override /// the modified by declaring a custom one in the subview. public struct GeneralizedDataEntryView<Wrapped: DataEntryView, Values: AccountValues>: View { + private var dataHookId: String { + "DataHook-\(Wrapped.Key.self)" + } + @EnvironmentObject private var account: Account @EnvironmentObject private var focusState: FocusStateObject @EnvironmentObject private var detailsBuilder: AccountValuesBuilder<Values> + @EnvironmentObject private var engines: ValidationEngines<String> @Environment(\.accountServiceConfiguration) private var configuration @Environment(\.accountViewType) private var viewType @@ -61,9 +66,16 @@ public struct GeneralizedDataEntryView<Wrapped: DataEntryView, Values: AccountVa // values like `GenderIdentity` provide a default value a user might not want to change if viewType?.enteringNewData == true, case let .default(value) = Wrapped.Key.initialValue { - detailsBuilder.set(Wrapped.Key.self, value: value) + engines.register(id: dataHookId) { + if detailsBuilder.get(Wrapped.Key.self) == nil { + detailsBuilder.set(Wrapped.Key.self, value: value) + } + } } } + .onDisappear { + engines.remove(hook: dataHookId) + } .onChange(of: value) { newValue in // ensure parent view has access to the latest value if viewType?.enteringNewData == true, diff --git a/Sources/SpeziAccount/Views/SignupForm.swift b/Sources/SpeziAccount/Views/SignupForm.swift index 437aed9d..7f45f516 100644 --- a/Sources/SpeziAccount/Views/SignupForm.swift +++ b/Sources/SpeziAccount/Views/SignupForm.swift @@ -11,6 +11,31 @@ import SpeziViews import SwiftUI +struct DefaultSignupFormHeader: View { + var body: some View { + VStack { + Image(systemName: "person.fill.badge.plus") + .foregroundColor(.accentColor) + .symbolRenderingMode(.multicolor) + .font(.custom("XXL", size: 50, relativeTo: .title)) + .accessibilityHidden(true) + Text("UP_SIGNUP_HEADER", bundle: .module) + .accessibilityAddTraits(.isHeader) + .font(.title) + .bold() + .padding(.bottom, 4) + Text("UP_SIGNUP_INSTRUCTIONS", bundle: .module) + .padding([.leading, .trailing], 25) + } + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .listRowBackground(Color.clear) + .padding(.top, -3) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } +} + + /// A generalized signup form used with arbitrary ``AccountService`` implementations. /// /// A `Form` that collects all configured account values (a ``AccountValueConfiguration`` supplied to ``AccountConfiguration``) @@ -32,21 +57,23 @@ public struct SignupForm<Service: AccountService, Header: View>: View { @State private var presentingCloseConfirmation = false - private var signupValuesBySections: OrderedDictionary<AccountKeyCategory, [any AccountKey.Type]> { - account.configuration.reduce(into: [:]) { result, configuration in - guard configuration.requirement != .supported else { - // we only show required and collected values in signup - return - } + private var accountKeyByCategory: OrderedDictionary<AccountKeyCategory, [any AccountKey.Type]> { + var result = account.configuration.allCategorized(filteredBy: [.required, .collected]) - result[configuration.key.category, default: []] += [configuration.key] + // patch the user configured account values with account values additionally required by + for entry in service.configuration.requiredAccountKeys { + let key = entry.key + if !result[key.category, default: []].contains(where: { $0 == key }) { + result[key.category, default: []].append(key) + } } + + return result } public var body: some View { form - .navigationTitle(Text("UP_SIGNUP", bundle: .module)) .disableDismissiveActions(isProcessing: viewState) .viewStateAlert(state: $viewState) .interactiveDismissDisabled(!signupDetailsBuilder.isEmpty) @@ -83,7 +110,7 @@ public struct SignupForm<Service: AccountService, Header: View>: View { Form { header - sectionsView + SignupSectionsView(for: SignupDetails.self, service: service, sections: accountKeyByCategory) .environment(\.accountServiceConfiguration, service.configuration) .environment(\.accountViewType, .signup) .environmentObject(signupDetailsBuilder) @@ -96,40 +123,18 @@ public struct SignupForm<Service: AccountService, Header: View>: View { .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) - .disabled(!validationEngines.allInputValid) .padding() .padding(-36) .listRowBackground(Color.clear) + .disabled(!validationEngines.allInputValid) } .environment(\.defaultErrorDescription, .init("UP_SIGNUP_FAILED_DEFAULT_ERROR", bundle: .atURL(from: .module))) } - @ViewBuilder var sectionsView: some View { - // OrderedDictionary `elements` conforms to RandomAccessCollection so we can directly use it - ForEach(signupValuesBySections.elements, id: \.key) { category, accountKeys in - Section { - // the array doesn't change, so its fine to rely on the indices as identifiers - ForEach(accountKeys.indices, id: \.self) { index in - VStack { - accountKeys[index].emptyDataEntryView(for: SignupDetails.self) - } - } - } header: { - if let title = category.categoryTitle { - Text(title) - } - } footer: { - if category == .credentials && account.configuration[PasswordKey.self] != nil { - PasswordValidationRuleFooter(configuration: service.configuration) - } - } - } - } - - init(using service: Service) where Header == Text { + init(using service: Service) where Header == DefaultSignupFormHeader { self.service = service - self.header = Text("UP_SIGNUP_INSTRUCTIONS", bundle: .module) + self.header = DefaultSignupFormHeader() } init(service: Service, @ViewBuilder header: () -> Header) { @@ -145,9 +150,9 @@ public struct SignupForm<Service: AccountService, Header: View>: View { focusedDataEntry = nil - let request: SignupDetails = try signupDetailsBuilder.build(checking: account.configuration) + let details: SignupDetails = try signupDetailsBuilder.build(checking: account.configuration) - try await service.signUp(signupDetails: request) + try await service.signUp(signupDetails: details) // go back if the view doesn't update anyway dismiss() @@ -160,13 +165,10 @@ struct DefaultUserIdPasswordSignUpView_Previews: PreviewProvider { static let accountService = MockUserIdPasswordAccountService() static var previews: some View { - Text("") - .sheet(isPresented: .constant(true)) { - NavigationStack { - SignupForm(using: accountService) - } - .environmentObject(Account(accountService)) - } + NavigationStack { + SignupForm(using: accountService) + } + .environmentObject(Account(accountService)) } } #endif diff --git a/Tests/UITests/TestApp/AccountTests/AccountTestsView.swift b/Tests/UITests/TestApp/AccountTests/AccountTestsView.swift index 7ea8b1fd..409954a9 100644 --- a/Tests/UITests/TestApp/AccountTests/AccountTestsView.swift +++ b/Tests/UITests/TestApp/AccountTests/AccountTestsView.swift @@ -33,41 +33,43 @@ struct AccountTestsView: View { Button("Account Overview") { showOverview = true } - } - .navigationTitle("Spezi Account") - .sheet(isPresented: $showSetup) { - NavigationStack { - AccountSetup { - finishButton - } - .toolbar { - toolbar(closing: $showSetup) + Button("Account Logout", role: .destructive) { + Task { + try? await account.details?.accountService.logout() } } + .disabled(!account.signedIn) } - .sheet(isPresented: $showOverview) { - NavigationStack { - AccountOverview(isEditing: $isEditing) { - NavigationLink { - Text("") - .navigationTitle(Text("Package Dependencies")) - } label: { - Text("License Information") - } - } + .navigationTitle("Spezi Account") + .sheet(isPresented: $showSetup) { + setupSheet() } - .toolbar { - toolbar(closing: $showOverview) + .sheet(isPresented: $showOverview) { + overviewSheet } - } } - .onChange(of: account.signedIn) { newValue in - if newValue { - showSetup = false + .accountRequired(features.accountRequiredModifier) { + setupSheet(closeable: false) } + .verifyRequiredAccountDetails(features.verifyRequiredDetails) + } + + @ViewBuilder var overviewSheet: some View { + NavigationStack { + AccountOverview(isEditing: $isEditing) { + NavigationLink { + Text("") + .navigationTitle(Text("Package Dependencies")) + } label: { + Text("License Information") + } + } + } + .toolbar { + toolbar(closing: $showOverview) } } - + @ViewBuilder var header: some View { if let details = account.details { Section("Account Details") { @@ -89,10 +91,26 @@ struct AccountTestsView: View { Text("Finish") .frame(maxWidth: .infinity, minHeight: 38) }) - .buttonStyle(.borderedProminent) + .buttonStyle(.borderedProminent) } + @ViewBuilder + func setupSheet(closeable: Bool = true) -> some View { + NavigationStack { + AccountSetup { _ in + showSetup = false + } continue: { + finishButton + } + .toolbar { + if closeable { + toolbar(closing: $showSetup) + } + } + } + } + @ToolbarContentBuilder func toolbar(closing flag: Binding<Bool>) -> some ToolbarContent { if !isEditing { diff --git a/Tests/UITests/TestApp/AccountTests/TestAccountConfiguration.swift b/Tests/UITests/TestApp/AccountTests/TestAccountConfiguration.swift index 4d45ef10..e6bf3d6d 100644 --- a/Tests/UITests/TestApp/AccountTests/TestAccountConfiguration.swift +++ b/Tests/UITests/TestApp/AccountTests/TestAccountConfiguration.swift @@ -18,15 +18,15 @@ final class TestAccountConfiguration: Component { init(features: Features) { switch features.serviceType { case .mail: - accountServices = [TestAccountService(.emailAddress, defaultAccount: features.defaultCredentials)] + accountServices = [TestAccountService(.emailAddress, defaultAccount: features.defaultCredentials, noName: features.noName)] case .both: accountServices = [ - TestAccountService(.emailAddress, defaultAccount: features.defaultCredentials), + TestAccountService(.emailAddress, defaultAccount: features.defaultCredentials, noName: features.noName), TestAccountService(.username) ] case .withIdentityProvider: accountServices = [ - TestAccountService(.emailAddress, defaultAccount: features.defaultCredentials), + TestAccountService(.emailAddress, defaultAccount: features.defaultCredentials, noName: features.noName), MockSignInWithAppleProvider() ] case .empty: diff --git a/Tests/UITests/TestApp/AccountTests/TestAccountService.swift b/Tests/UITests/TestApp/AccountTests/TestAccountService.swift index 040782c5..609c5ebb 100644 --- a/Tests/UITests/TestApp/AccountTests/TestAccountService.swift +++ b/Tests/UITests/TestApp/AccountTests/TestAccountService.swift @@ -13,12 +13,13 @@ actor TestAccountService: UserIdPasswordAccountService { nonisolated let configuration: AccountServiceConfiguration private let defaultUserId: String private let defaultAccountOnConfigure: Bool + private var excludeName: Bool @AccountReference var account: Account var registeredUser: UserStorage // simulates the backend - init(_ type: UserIdType, defaultAccount: Bool = false) { + init(_ type: UserIdType, defaultAccount: Bool = false, noName: Bool = false) { configuration = AccountServiceConfiguration( name: "\(type.localizedStringResource) and Password", supportedKeys: .exactly(UserStorage.supportedKeys) @@ -32,6 +33,7 @@ actor TestAccountService: UserIdPasswordAccountService { defaultUserId = type == .emailAddress ? UserStorage.defaultEmail : UserStorage.defaultUsername self.defaultAccountOnConfigure = defaultAccount + self.excludeName = noName registeredUser = UserStorage(userId: defaultUserId) } @@ -81,11 +83,20 @@ actor TestAccountService: UserIdPasswordAccountService { } func updateUser() async throws { - let details = AccountDetails.Builder() + let builder = AccountDetails.Builder() + .set(\.accountId, value: registeredUser.accountId.uuidString) .set(\.userId, value: registeredUser.userId) - .set(\.name, value: registeredUser.name) .set(\.genderIdentity, value: registeredUser.genderIdentity) .set(\.dateOfBirth, value: registeredUser.dateOfBirth) + .set(\.biography, value: registeredUser.biography) + + if !self.excludeName { + builder.set(\.name, value: registeredUser.name) + } else { + excludeName = false + } + + let details = builder .build(owner: self) try await account.supplyUserDetails(details) diff --git a/Tests/UITests/TestApp/AccountTests/UserStorage.swift b/Tests/UITests/TestApp/AccountTests/UserStorage.swift index 755c40fd..7e09c246 100644 --- a/Tests/UITests/TestApp/AccountTests/UserStorage.swift +++ b/Tests/UITests/TestApp/AccountTests/UserStorage.swift @@ -18,6 +18,7 @@ struct UserStorage { .day(.twoDigits) static let supportedKeys = AccountKeyCollection { + \.accountId \.userId \.password \.name @@ -28,20 +29,24 @@ struct UserStorage { static let defaultUsername = "lelandstanford" static let defaultEmail = "lelandstanford@stanford.edu" + var accountId: UUID var userId: String var password: String var name: PersonNameComponents? var genderIdentity: GenderIdentity? var dateOfBirth: Date? + var biography: String? - init( + init( // swiftlint:disable:this function_default_parameter_at_end + accountId: UUID = UUID(), userId: String, password: String = "StanfordRocks123!", name: PersonNameComponents? = PersonNameComponents(givenName: "Leland", familyName: "Stanford"), gender: GenderIdentity? = .male, dateOfBirth: Date? = try? Date("09.03.1824", strategy: dateStyle) ) { + self.accountId = accountId self.userId = userId self.password = password self.name = name @@ -59,6 +64,7 @@ struct UserStorage { self.name = modifiedDetails.name ?? name self.genderIdentity = modifiedDetails.genderIdentity ?? genderIdentity self.dateOfBirth = modifiedDetails.dateOfBrith ?? dateOfBirth + self.biography = modifiedDetails.biography ?? biography // user Id cannot be removed! if removedKeys.name != nil { @@ -70,5 +76,8 @@ struct UserStorage { if removedKeys.dateOfBrith != nil { self.dateOfBirth = nil } + if removedKeys.biography != nil { + self.biography = nil + } } } diff --git a/Tests/UITests/TestApp/Features.swift b/Tests/UITests/TestApp/Features.swift index b1680cb5..86df3d58 100644 --- a/Tests/UITests/TestApp/Features.swift +++ b/Tests/UITests/TestApp/Features.swift @@ -21,6 +21,7 @@ enum AccountServiceType: String, ExpressibleByArgument { enum AccountValueConfigurationType: String, ExpressibleByArgument { case `default` case allRequired + case allRequiredWithBio } @@ -33,6 +34,12 @@ struct Features: ParsableArguments, EnvironmentKey { @Option(help: "Define which type of AccountValueConfiguration is used.") var configurationType: AccountValueConfigurationType = .default @Flag(help: "Control if the app should be populated with default credentials.") var defaultCredentials = false + + @Flag(help: "Enable the AccountRequiredModifier.swift") var accountRequiredModifier = false + + @Flag(help: "Enable the VerifyRequiredAccountDetailsModifier") var verifyRequiredDetails = false + + @Flag(help: "Set no name by default.") var noName = false } diff --git a/Tests/UITests/TestApp/TestApp.entitlements b/Tests/UITests/TestApp/TestApp.entitlements new file mode 100644 index 00000000..a812db50 --- /dev/null +++ b/Tests/UITests/TestApp/TestApp.entitlements @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>com.apple.developer.applesignin</key> + <array> + <string>Default</string> + </array> +</dict> +</plist> diff --git a/Tests/UITests/TestApp/TestApp.entitlements.license b/Tests/UITests/TestApp/TestApp.entitlements.license new file mode 100644 index 00000000..7f16969d --- /dev/null +++ b/Tests/UITests/TestApp/TestApp.entitlements.license @@ -0,0 +1,5 @@ +This source file is part of the Stanford Spezi open-source project + +SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) + +SPDX-License-Identifier: MIT \ No newline at end of file diff --git a/Tests/UITests/TestApp/TestAppDelegate.swift b/Tests/UITests/TestApp/TestAppDelegate.swift index 9dce92a1..abc3e20c 100644 --- a/Tests/UITests/TestApp/TestAppDelegate.swift +++ b/Tests/UITests/TestApp/TestAppDelegate.swift @@ -6,9 +6,11 @@ // SPDX-License-Identifier: MIT // +import Foundation import Spezi import SpeziAccount + class TestAppDelegate: SpeziAppDelegate { let features: Features = { do { @@ -16,7 +18,7 @@ class TestAppDelegate: SpeziAppDelegate { return features } catch { print("Error: \(error)") - print("Verify the supplied command line arguments: " + CommandLine.arguments.dropFirst().joined(separator: " ")) + print("Verify the supplied command line arguments: " + ProcessInfo.processInfo.arguments.dropFirst().joined(separator: " ")) print(Features.helpMessage()) return Features() } @@ -27,7 +29,6 @@ class TestAppDelegate: SpeziAppDelegate { case .default: return [ .requires(\.userId), - .requires(\.password), .collects(\.name), .collects(\.genderIdentity), .collects(\.dateOfBirth), @@ -36,10 +37,18 @@ class TestAppDelegate: SpeziAppDelegate { case .allRequired: return [ .requires(\.userId), - .requires(\.password), .requires(\.name), .requires(\.genderIdentity), - .requires(\.dateOfBirth) + .requires(\.dateOfBirth), + .supports(\.biography) // that's special case for checking follow up info on e.g. login + ] + case .allRequiredWithBio: + return [ + .requires(\.userId), + .requires(\.name), + .requires(\.genderIdentity), + .requires(\.dateOfBirth), + .requires(\.biography) ] } } diff --git a/Tests/UITests/TestAppUITests/AccountOverviewTests.swift b/Tests/UITests/TestAppUITests/AccountOverviewTests.swift index d29f131c..c0e60459 100644 --- a/Tests/UITests/TestAppUITests/AccountOverviewTests.swift +++ b/Tests/UITests/TestAppUITests/AccountOverviewTests.swift @@ -25,7 +25,7 @@ final class AccountOverviewTests: XCTestCase { overview.verifyExistence(text: "lelandstanford@stanford.edu") overview.verifyExistence(text: "Name, E-Mail Address") - overview.verifyExistence(text: "Password & Security") + overview.verifyExistence(text: "Sign-In & Security") overview.verifyExistence(text: "Gender Identity, Male") @@ -213,13 +213,37 @@ final class AccountOverviewTests: XCTestCase { XCTAssertTrue(overview.staticTexts["L"].waitForExistence(timeout: 2.0)) // ensure the "account image" is updated accordingly } + func testAddName() throws { + let app = TestApp.launch(defaultCredentials: true, noName: true) + let overview = app.openAccountOverview() + + overview.verifyExistence(text: "Name, E-Mail Address") + + overview.tap(button: "Name, E-Mail Address") + sleep(2) + XCTAssertTrue(overview.navigationBars.staticTexts["Name, E-Mail Address"].waitForExistence(timeout: 6.0)) + + // open user id + overview.tap(button: "Add Name") + sleep(2) + XCTAssertFalse(overview.buttons["Done"].isEnabled) + + try overview.enter(field: "enter first name", text: "Leland") + try overview.enter(field: "enter last name", text: "Stanford") + + overview.tap(button: "Done") + sleep(3) + + overview.verifyExistence(text: "Name, Leland Stanford") + } + func testSecurityOverview() throws { let app = TestApp.launch(defaultCredentials: true) let overview = app.openAccountOverview() - overview.tap(button: "Password & Security") + overview.tap(button: "Sign-In & Security") sleep(2) - XCTAssertTrue(overview.navigationBars.staticTexts["Password & Security"].waitForExistence(timeout: 6.0)) + XCTAssertTrue(overview.navigationBars.staticTexts["Sign-In & Security"].waitForExistence(timeout: 6.0)) XCTAssertTrue(overview.buttons["Change Password"].waitForExistence(timeout: 2.0)) overview.tap(button: "Change Password") diff --git a/Tests/UITests/TestAppUITests/AccountSetupTests.swift b/Tests/UITests/TestAppUITests/AccountSetupTests.swift index 5a27bdf0..25612098 100644 --- a/Tests/UITests/TestAppUITests/AccountSetupTests.swift +++ b/Tests/UITests/TestAppUITests/AccountSetupTests.swift @@ -160,7 +160,7 @@ final class AccountSetupTests: XCTestCase { XCTAssertEqual(signupView.staticTexts.matching(identifier: "This field cannot be empty.").count, 2) // not sure why, but text-field selection has issues due to the presented validation messages, so we exit a reenter to resolve this - setup = signupView.tapClose() + setup = try signupView.tapClose() signupView = setup.openSignup() // enter email with validation @@ -184,7 +184,6 @@ final class AccountSetupTests: XCTestCase { XCTAssertTrue(overview.staticTexts[email].waitForExistence(timeout: 2.0)) XCTAssertTrue(overview.staticTexts["Gender Identity"].waitForExistence(timeout: 0.5)) XCTAssertTrue(overview.staticTexts["Choose not to answer"].waitForExistence(timeout: 0.5)) - XCTAssertTrue(overview.images["Contact Photo"].waitForExistence(timeout: 0.5)) // verify the header works well without a name } func testNameValidation() throws { @@ -287,7 +286,6 @@ final class AccountSetupTests: XCTestCase { XCTAssertTrue(overview.staticTexts[email].waitForExistence(timeout: 2.0)) XCTAssertTrue(overview.staticTexts["Gender Identity"].waitForExistence(timeout: 0.5)) XCTAssertTrue(overview.staticTexts["Choose not to answer"].waitForExistence(timeout: 0.5)) - XCTAssertTrue(overview.images["Contact Photo"].waitForExistence(timeout: 0.5)) overview.tap(button: "Name, E-Mail Address") sleep(2) @@ -297,4 +295,39 @@ final class AccountSetupTests: XCTestCase { XCTAssertFalse(overview.staticTexts["Leland"].waitForExistence(timeout: 1.0)) overview.verifyExistence(text: "Add Name") } + + func testAdditionalInfoAfterLogin() throws { + let app = TestApp.launch(config: "allRequiredWithBio") + + let setup = app.openAccountSetup() + + try setup.login(email: Defaults.email, password: Defaults.password) + + // verify the finish account setup view is popping up + XCTAssertTrue(setup.staticTexts["Finish Account Setup"].waitForExistence(timeout: 2.0)) + XCTAssertTrue(setup.staticTexts["Please fill out the details below to complete your account setup."].waitForExistence(timeout: 0.5)) + + try setup.enter(field: "Biography", text: "Hello Stanford") + sleep(2) + + setup.tap(button: "Complete") + sleep(3) + + // verify we are back at the start screen + XCTAssertTrue(app.staticTexts[Defaults.email].waitForExistence(timeout: 2.0)) + } + + func testAccountRequiredModifier() throws { + let app = TestApp.launch(defaultCredentials: true, accountRequired: true) + + app.tap(button: "Account Logout") + + XCTAssertTrue(app.staticTexts["Your Account"].waitForExistence(timeout: 2.0)) + } + + func testVerifyRequiredAccountDetailsModifier() throws { + let app = TestApp.launch(config: "allRequiredWithBio", defaultCredentials: true, verifyAccountDetails: true) + + XCTAssertTrue(app.staticTexts["Finish Account Setup"].waitForExistence(timeout: 2.0)) + } } diff --git a/Tests/UITests/TestAppUITests/Utils/SignupView.swift b/Tests/UITests/TestAppUITests/Utils/SignupView.swift index 5953a1f4..01f6f0f5 100644 --- a/Tests/UITests/TestAppUITests/Utils/SignupView.swift +++ b/Tests/UITests/TestAppUITests/Utils/SignupView.swift @@ -17,7 +17,7 @@ struct SignupView: AccountValueView { } func verify() { - XCTAssertTrue(app.staticTexts["Please fill out the details below to create a new account."].waitForExistence(timeout: 6.0)) + XCTAssertTrue(app.staticTexts["Please fill out the details below to create your new account."].waitForExistence(timeout: 6.0)) } func fillForm( @@ -62,9 +62,9 @@ struct SignupView: AccountValueView { } } - func tapClose(timeout: TimeInterval = 1.0, discardChangesIfAsked: Bool = true) -> TestableAccountSetup { - XCTAssertTrue(app.navigationBars["Signup"].buttons["Close"].waitForExistence(timeout: timeout)) - app.navigationBars["Signup"].buttons["Close"].tap() + func tapClose(timeout: TimeInterval = 1.0, discardChangesIfAsked: Bool = true) throws -> TestableAccountSetup { + XCTAssertTrue(app.navigationBars.buttons["Close"].waitForExistence(timeout: timeout)) + try XCTUnwrap(app.navigationBars.buttons.matching(identifier: "Close").allElementsBoundByIndex.last).tap() if discardChangesIfAsked && app.staticTexts["Are you sure you want to discard your input?"].waitForExistence(timeout: 2.0) { tap(button: "Discard Input") diff --git a/Tests/UITests/TestAppUITests/Utils/TestApp.swift b/Tests/UITests/TestAppUITests/Utils/TestApp.swift index c2b0d164..f6f58eb7 100644 --- a/Tests/UITests/TestAppUITests/Utils/TestApp.swift +++ b/Tests/UITests/TestAppUITests/Utils/TestApp.swift @@ -20,12 +20,18 @@ struct TestApp: TestableView { serviceType: String = "mail", config: String = "default", defaultCredentials: Bool = false, + accountRequired: Bool = false, + verifyAccountDetails: Bool = false, + noName: Bool = false, flags: String... ) -> TestApp { let app = XCUIApplication() app.launchArguments = ["--service-type", serviceType, "--configuration-type", config] - + (defaultCredentials ? ["--default-credentials"] : []) - + flags + app.launchArguments += (defaultCredentials ? ["--default-credentials"] : []) + app.launchArguments += (accountRequired ? ["--account-required-modifier"] : []) + app.launchArguments += (verifyAccountDetails ? ["--verify-required-details"] : []) + app.launchArguments += (noName ? ["--no-name"] : []) + app.launchArguments += flags app.launch() let testApp = TestApp(app: app) diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index c03fd30c..f43e6747 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -54,6 +54,7 @@ 2F6D139228F5F384007C25D6 /* TestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TestApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 2F6D139928F5F386007C25D6 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 2F6D13AC28F5F386007C25D6 /* TestAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TestAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 2FA43E8F2AE022F1009B1B2C /* TestApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TestApp.entitlements; sourceTree = "<group>"; }; 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestApp.swift; sourceTree = "<group>"; }; 2FAD38BE2A455F7D00E79ED1 /* SpeziAccount */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = SpeziAccount; path = ../..; sourceTree = "<group>"; }; 2FE750C92A8720CE00723EAE /* TestApp.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestApp.xctestplan; sourceTree = "<group>"; }; @@ -132,6 +133,7 @@ 2F6D139428F5F384007C25D6 /* TestApp */ = { isa = PBXGroup; children = ( + 2FA43E8F2AE022F1009B1B2C /* TestApp.entitlements */, 2F027C9629D6C63300234098 /* AccountTests */, A9EE7D292A3359E800C2B9A9 /* Features.swift */, 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */, @@ -446,6 +448,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = TestApp/TestApp.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; @@ -477,6 +480,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = TestApp/TestApp.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; diff --git a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme index 653e7f3f..a8cacf49 100644 --- a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme +++ b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme @@ -62,6 +62,14 @@ argument = "mail" isEnabled = "YES"> </CommandLineArgument> + <CommandLineArgument + argument = "--configuration-type" + isEnabled = "NO"> + </CommandLineArgument> + <CommandLineArgument + argument = "allRequiredWithBio" + isEnabled = "NO"> + </CommandLineArgument> <CommandLineArgument argument = "--default-credentials" isEnabled = "NO">