Skip to content

Commit

Permalink
Introduce the AccountNotifyStandard (StanfordSpezi#24)
Browse files Browse the repository at this point in the history
# Introduce the AccountNotifyStandard

## ♻️ Current situation & Problem
Currently, there is no way for an App to distinguish between Account
logout and removal. This is especially important if the App associates
other data (questionnaires, additional user information) with the user
account that must be deleted as well.

This PR adds the `AccountNotifyStandard` constraint that allows to get
notified about an account removal.

## ⚙️ Release Notes 
* Add `AccountNotifyStandard`


## 📚 Documentation
Documentation was added and updated.


## ✅ Testing
Tests were added to verify functionality.


## 📝 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).
  • Loading branch information
Supereg authored Sep 20, 2023
1 parent 1685d1c commit 5937ed6
Show file tree
Hide file tree
Showing 11 changed files with 212 additions and 62 deletions.
12 changes: 7 additions & 5 deletions Sources/SpeziAccount/Account.swift
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ public class Account: ObservableObject, Sendable {
var details = details

// 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` carray the correct reference.
// if they are wrapped into a StandardBacked one such that the `AccountDetails` carry the correct reference.
for service in registeredAccountServices {
if let standardBacked = service as? any StandardBacked,
standardBacked.isBacking(service: details.accountService) {
Expand All @@ -204,14 +204,15 @@ public class Account: ObservableObject, Sendable {
)
}

if let standardBacked = details.accountService as? any StandardBacked {
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 unsupportedKeys = details.accountService.configuration
.unsupportedAccountKeys(basedOn: configuration)
.map { $0.key }

let partialDetails = try await standardBacked.standard.load(recordId, unsupportedKeys)
let partialDetails = try await storageStandard.load(recordId, unsupportedKeys)

self.details = details.merge(with: partialDetails, allowOverwrite: false)
} else {
Expand All @@ -229,10 +230,11 @@ public class Account: ObservableObject, Sendable {
/// signed in user and notify others that the user logged out (or the account was removed).
public func removeUserDetails() async {
if let details,
let standardBacked = details.accountService as? any StandardBacked {
let standardBacked = details.accountService as? any StandardBacked,
let storageStandard = standardBacked.standard as? any AccountStorageStandard {
let recordId = AdditionalRecordId(serviceId: standardBacked.backedId, userId: details.userId)

await standardBacked.standard.clear(recordId)
await storageStandard.clear(recordId)
}

if signedIn {
Expand Down
8 changes: 7 additions & 1 deletion Sources/SpeziAccount/AccountConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,13 @@ public final class AccountConfiguration: Component, ObservableObjectProvider {

// Verify account service can store all configured account keys.
// If applicable, wraps the service into an StandardBackedAccountService
return verifyConfigurationRequirements(against: service)
let service = verifyConfigurationRequirements(against: service)

if let notifyStandard = standard as? any AccountNotifyStandard {
return service.backedBy(standard: notifyStandard)
}

return service
}

self.account = Account(
Expand Down
18 changes: 18 additions & 0 deletions Sources/SpeziAccount/AccountNotifyStandard.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//
// 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


/// A `Spezi` Standard that allows to react to certain Account-based events.
public protocol AccountNotifyStandard: Standard {
/// Notifies the Standard that the associated account was requested to be deleted by the user.
///
/// Use this method to cleanup any account related data that might be associated with the account.
func deletedAccount() async throws
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
//
// 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


actor NotifyStandardBackedAccountService<Service: AccountService, Standard: AccountNotifyStandard>: AccountService, StandardBacked {
@AccountReference private var account

let accountService: Service
let standard: Standard

nonisolated var configuration: AccountServiceConfiguration {
accountService.configuration
}

nonisolated var viewStyle: Service.ViewStyle {
accountService.viewStyle
}


init(service accountService: Service, standard: Standard) {
self.accountService = accountService
self.standard = standard
}


func delete() async throws {
try await standard.deletedAccount()
try await accountService.delete()
}
}


extension NotifyStandardBackedAccountService: EmbeddableAccountService where Service: EmbeddableAccountService {}

extension NotifyStandardBackedAccountService: UserIdPasswordAccountService where Service: UserIdPasswordAccountService {}

extension NotifyStandardBackedAccountService: IdentityProvider where Service: IdentityProvider {}
97 changes: 97 additions & 0 deletions Sources/SpeziAccount/AccountService/Wrapper/StandardBacked.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
//
// 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


/// Internal marker protocol to determine what ``AccountService`` require assistance by a ``AccountStorageStandard``.
protocol StandardBacked: AccountService {
associatedtype Service: AccountService
associatedtype AccountStandard: Standard

var accountService: Service { get }
var standard: AccountStandard { get }

init(service: Service, standard: AccountStandard)

func isBacking(service accountService: any AccountService) -> Bool
}


extension StandardBacked {
var backedId: String {
if let nestedBacked = accountService as? any StandardBacked {
return nestedBacked.backedId
}

return accountService.id
}


func isBacking(service: any AccountService) -> Bool {
if let nestedBacked = self.accountService as? any StandardBacked {
return nestedBacked.isBacking(service: service)
}
return self.accountService.objId == service.objId
}
}


extension StandardBacked {
func signUp(signupDetails: SignupDetails) async throws {
try await accountService.signUp(signupDetails: signupDetails)
}

func logout() async throws {
try await accountService.logout()
}

func delete() async throws {
try await accountService.delete()
}

func updateAccountDetails(_ modifications: AccountModifications) async throws {
try await accountService.updateAccountDetails(modifications)
}
}


extension StandardBacked where Self: UserIdPasswordAccountService, Service: UserIdPasswordAccountService {
func login(userId: String, password: String) async throws {
try await accountService.login(userId: userId, password: password)
}

func resetPassword(userId: String) async throws {
try await accountService.resetPassword(userId: userId)
}
}


extension AccountService {
func backedBy(standard: any AccountStorageStandard) -> any AccountService {
standard.backedService(with: self)
}

func backedBy(standard: any AccountNotifyStandard) -> any AccountService {
standard.backedService(with: self)
}
}


extension AccountStorageStandard {
fileprivate nonisolated func backedService<Service: AccountService>(with service: Service) -> any AccountService {
StorageStandardBackedAccountService(service: service, standard: self)
}
}


extension AccountNotifyStandard {
fileprivate nonisolated func backedService<Service: AccountService>(with service: Service) -> any AccountService {
NotifyStandardBackedAccountService(service: service, standard: self)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,10 @@

import Spezi

/// Internal marker protocol to determine what ``AccountService`` require assistance by a ``AccountStorageStandard``.
protocol StandardBacked {
associatedtype Standard: AccountStorageStandard
var standard: Standard { get }

var backedId: String { get }

func isBacking(service accountService: any AccountService) -> Bool
}


/// An ``AccountService`` implementation for account services with ``SupportedAccountKeys/exactly(_:)`` configuration
/// to forward unsupported account values to a ``AccountStorageStandard`` implementation.
actor StandardBackedAccountService<Service: AccountService, Standard: AccountStorageStandard>: AccountService, StandardBacked {
actor StorageStandardBackedAccountService<Service: AccountService, Standard: AccountStorageStandard>: AccountService, StandardBacked {
@AccountReference private var account

let accountService: Service
Expand All @@ -36,10 +26,6 @@ actor StandardBackedAccountService<Service: AccountService, Standard: AccountSto
accountService.viewStyle
}

nonisolated var backedId: String {
accountService.id
}


private var currentUserId: String? {
get async {
Expand All @@ -59,10 +45,6 @@ actor StandardBackedAccountService<Service: AccountService, Standard: AccountSto
}


nonisolated func isBacking(service accountService: any AccountService) -> Bool {
self.accountService.objId == accountService.objId
}

func signUp(signupDetails: SignupDetails) async throws {
let details = splitDetails(from: signupDetails)

Expand Down Expand Up @@ -100,10 +82,6 @@ actor StandardBackedAccountService<Service: AccountService, Standard: AccountSto
try await accountService.updateAccountDetails(serviceModifications)
}

func logout() async throws {
try await accountService.logout()
}

func delete() async throws {
guard let userId = await currentUserId else {
return
Expand Down Expand Up @@ -140,30 +118,8 @@ actor StandardBackedAccountService<Service: AccountService, Standard: AccountSto
}


extension StandardBackedAccountService: EmbeddableAccountService where Service: EmbeddableAccountService {}


extension StandardBackedAccountService: UserIdPasswordAccountService where Service: UserIdPasswordAccountService {
func login(userId: String, password: String) async throws {
// the standard is queried once the account service calls `supplyAccountDetails`
try await accountService.login(userId: userId, password: password)
}

func resetPassword(userId: String) async throws {
try await accountService.resetPassword(userId: userId)
}
}


extension AccountService {
func backedBy(standard: any AccountStorageStandard) -> any AccountService {
standard.backedService(with: self)
}
}
extension StorageStandardBackedAccountService: EmbeddableAccountService where Service: EmbeddableAccountService {}

extension StorageStandardBackedAccountService: UserIdPasswordAccountService where Service: UserIdPasswordAccountService {}

extension AccountStorageStandard {
fileprivate nonisolated func backedService<Service: AccountService>(with service: Service) -> any AccountService {
StandardBackedAccountService(service: service, standard: self)
}
}
extension StorageStandardBackedAccountService: IdentityProvider where Service: IdentityProvider {}
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,7 @@ struct MyView: View {

- ``AccountSetup``
- ``AccountOverview``

### Reacting to Events

- ``AccountNotifyStandard``
23 changes: 16 additions & 7 deletions Tests/UITests/TestApp/AccountTests/AccountTestsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,17 @@ struct AccountTestsView: View {
@Environment(\.features) var features

@EnvironmentObject var account: Account
@EnvironmentObject var standard: TestStandard

@State var showSetup = false
@State var showOverview = false
@State var isEditing = false


var body: some View {
// of by two
NavigationStack { // swiftlint:disable:this closure_body_length
NavigationStack {
List {
if let details = account.details {
Section("Account Details") {
Text(details.userId)
}
}
header
Button("Account Setup") {
showSetup = true
}
Expand Down Expand Up @@ -65,6 +61,19 @@ struct AccountTestsView: View {
}
}

@ViewBuilder var header: some View {
if let details = account.details {
Section("Account Details") {
Text(details.userId)
}
}
if standard.deleteNotified {
Section {
Text("Got notified about deletion!")
}
}
}


@ViewBuilder var finishButton: some View {
Button(action: {
Expand Down
10 changes: 9 additions & 1 deletion Tests/UITests/TestApp/AccountTests/TestStandard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@

import Spezi
import SpeziAccount
import SwiftUI


// mock implementation of the AccountStorageStandard
actor TestStandard: AccountStorageStandard {
actor TestStandard: AccountStorageStandard, AccountNotifyStandard, ObservableObject, ObservableObjectProvider {
@MainActor @Published var deleteNotified = false

var records: [AdditionalRecordId: PartialAccountDetails.Builder] = [:]

func create(_ identifier: AdditionalRecordId, _ details: SignupDetails) async throws {
Expand Down Expand Up @@ -40,4 +43,9 @@ actor TestStandard: AccountStorageStandard {
func delete(_ identifier: AdditionalRecordId) async throws {
records[identifier] = nil
}

@MainActor
func deletedAccount() async {
deleteNotified = true
}
}
2 changes: 2 additions & 0 deletions Tests/UITests/TestAppUITests/AccountOverviewTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,9 @@ final class AccountOverviewTests: XCTestCase {

sleep(2)
app.verify()

XCTAssertFalse(app.staticTexts["[email protected]"].waitForExistence(timeout: 0.5))
XCTAssertTrue(app.staticTexts["Got notified about deletion!"].waitForExistence(timeout: 2.0))
}

func testEditDiscard() {
Expand Down
Loading

0 comments on commit 5937ed6

Please sign in to comment.