Skip to content

Commit

Permalink
Feature/invite authentication (#3)
Browse files Browse the repository at this point in the history
# Study Enrollment via Invitation Code Authentication and Customize
Onboarding

## ♻️ Current situation & Problem
Currently, there is no way to validate the identity of the user of the
app to enroll them in the study.


## ⚙️ Release Notes 
- Customized Spezi Onboarding Flow to have language and symbols relevant
to Engage
- Integrated invitation code authorization into the onboarding flow,
using cloud functions to validate supplied code against a database of
valid codes stored in Firebase.


## 📚 Documentation
Integrated code based on:
StanfordBDHG/PediatricAppleWatchStudy#54.

The Onboarding flow now has custom views:

<img width="256" alt="Screenshot 2024-04-09 at 10 24 14 PM"
src="https://github.com/StanfordBDHG/ENGAGE-HF/assets/108841122/ee4b1fe0-d560-4918-8de0-28be1d7aa7b3">
<img width="256" alt="Screenshot 2024-04-09 at 10 26 21 PM"
src="https://github.com/StanfordBDHG/ENGAGE-HF/assets/108841122/a06f18b1-a944-4008-968b-0eab1aa36ca0">
<img width="256" alt="Screenshot 2024-04-09 at 10 26 28 PM"
src="https://github.com/StanfordBDHG/ENGAGE-HF/assets/108841122/57d05f25-86d5-409c-8d92-31f25c7a17ed">

See SpeziOnboarding for documentation of the other views.

## ✅ Testing
Thoroughly tested the onboarding flow, including the invitation code
authorization and account creation process.


### Code of Conduct & Contributing Guidelines 

By submitting creating this pull request, you agree to follow our [Code
of
Conduct](https://github.com/StanfordBDHG/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordBDHG/.github/blob/main/CONTRIBUTING.md):
- [X] I agree to follow the [Code of
Conduct](https://github.com/StanfordBDHG/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordBDHG/.github/blob/main/CONTRIBUTING.md).

---------

Co-authored-by: Paul Schmiedmayer <[email protected]>
  • Loading branch information
nriedman and PSchmiedmayer authored Apr 11, 2024
1 parent db813e6 commit 15b47f6
Show file tree
Hide file tree
Showing 29 changed files with 7,789 additions and 33 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ jobs:
runsonlabels: '["macOS", "self-hosted"]'
setupSimulators: true
setupfirebaseemulator: true
customcommand: "firebase emulators:exec 'fastlane test'"
customcommand: "npm install --previx ./functions && firebase emulators:exec --import=./firebase 'fastlane test'"
uploadcoveragereport:
name: Upload Coverage Report
needs: buildandtest
Expand Down
6 changes: 6 additions & 0 deletions .reuse/dep5
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/

Files: firebase/*
Copyright: 2024 Stanford University and the project authors (see CONTRIBUTORS.md)
License: MIT
Comment: All files are part of the ENGAGE-HF application as seeded data for the Firebase emulator.
20 changes: 20 additions & 0 deletions ENGAGEHF.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,13 @@
2FE5DC9C29EDD9EF004B9AB4 /* XCTHealthKit in Frameworks */ = {isa = PBXBuildFile; productRef = 2FE5DC9B29EDD9EF004B9AB4 /* XCTHealthKit */; };
2FE5DCB129EE6107004B9AB4 /* AccountOnboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE5DCAC29EE6107004B9AB4 /* AccountOnboarding.swift */; };
2FF53D8D2A8729D600042B76 /* ENGAGEHFStandard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FF53D8C2A8729D600042B76 /* ENGAGEHFStandard.swift */; };
4D4AA0A52BC5E43E00676489 /* OnboardingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D4AA0A42BC5E43E00676489 /* OnboardingUITests.swift */; };
4DB025CA2BBE3A59002D2545 /* HomeViewUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB025C92BBE3A59002D2545 /* HomeViewUITests.swift */; };
4DB025D52BBF2E08002D2545 /* Dashboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB025D42BBF2E08002D2545 /* Dashboard.swift */; };
4DB025D82BBF2EEC002D2545 /* Greeting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB025D72BBF2EEC002D2545 /* Greeting.swift */; };
4DBDD3442BBFAD64001FB0CA /* InvitationCodeError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DBDD3432BBFAD64001FB0CA /* InvitationCodeError.swift */; };
4DBDD3462BBFAE2D001FB0CA /* InvitationCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DBDD3452BBFAE2D001FB0CA /* InvitationCodeView.swift */; };
4DBDD3482BC073EF001FB0CA /* FirebaseFunctions in Frameworks */ = {isa = PBXBuildFile; productRef = 4DBDD3472BC073EF001FB0CA /* FirebaseFunctions */; };
56E708352BB06B7100B08F0A /* SpeziLicense in Frameworks */ = {isa = PBXBuildFile; productRef = 56E708342BB06B7100B08F0A /* SpeziLicense */; };
56E7083B2BB06F6F00B08F0A /* SwiftPackageList in Frameworks */ = {isa = PBXBuildFile; productRef = 56E7083A2BB06F6F00B08F0A /* SwiftPackageList */; };
653A2551283387FE005D4D48 /* ENGAGEHF.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653A2550283387FE005D4D48 /* ENGAGEHF.swift */; };
Expand Down Expand Up @@ -108,9 +112,12 @@
2FE5DC5529EDD811004B9AB4 /* SocialSupportQuestionnaire.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SocialSupportQuestionnaire.json; sourceTree = "<group>"; };
2FE5DCAC29EE6107004B9AB4 /* AccountOnboarding.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountOnboarding.swift; sourceTree = "<group>"; };
2FF53D8C2A8729D600042B76 /* ENGAGEHFStandard.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ENGAGEHFStandard.swift; sourceTree = "<group>"; };
4D4AA0A42BC5E43E00676489 /* OnboardingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingUITests.swift; sourceTree = "<group>"; };
4DB025C92BBE3A59002D2545 /* HomeViewUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewUITests.swift; sourceTree = "<group>"; };
4DB025D42BBF2E08002D2545 /* Dashboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dashboard.swift; sourceTree = "<group>"; };
4DB025D72BBF2EEC002D2545 /* Greeting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Greeting.swift; sourceTree = "<group>"; };
4DBDD3432BBFAD64001FB0CA /* InvitationCodeError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitationCodeError.swift; sourceTree = "<group>"; };
4DBDD3452BBFAE2D001FB0CA /* InvitationCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitationCodeView.swift; sourceTree = "<group>"; };
653A254D283387FE005D4D48 /* ENGAGEHF.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ENGAGEHF.app; sourceTree = BUILT_PRODUCTS_DIR; };
653A2550283387FE005D4D48 /* ENGAGEHF.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ENGAGEHF.swift; sourceTree = "<group>"; };
653A255428338800005D4D48 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
Expand Down Expand Up @@ -145,6 +152,7 @@
2FE5DC7529EDD8E6004B9AB4 /* SpeziFirebaseAccount in Frameworks */,
A92E4DF02BAA001100AC8DE8 /* OrderedCollections in Frameworks */,
9739A0C62AD7B5730084BEA5 /* FirebaseStorage in Frameworks */,
4DBDD3482BC073EF001FB0CA /* FirebaseFunctions in Frameworks */,
2FE5DC7229EDD8D3004B9AB4 /* SpeziHealthKit in Frameworks */,
2F49B7762980407C00BCB272 /* Spezi in Frameworks */,
2FE5DC8F29EDD980004B9AB4 /* SpeziViews in Frameworks */,
Expand Down Expand Up @@ -200,6 +208,8 @@
2FE5DC2F29EDD7CA004B9AB4 /* Consent.swift */,
2FE5DC3029EDD7CA004B9AB4 /* HealthKitPermissions.swift */,
2F65B44D2A3B8B0600A36932 /* NotificationPermissions.swift */,
4DBDD3432BBFAD64001FB0CA /* InvitationCodeError.swift */,
4DBDD3452BBFAE2D001FB0CA /* InvitationCodeView.swift */,
);
path = Onboarding;
sourceTree = "<group>";
Expand Down Expand Up @@ -299,6 +309,7 @@
children = (
2F4E237D2989A2FE0013F3D9 /* LaunchTests.swift */,
4DB025C92BBE3A59002D2545 /* HomeViewUITests.swift */,
4D4AA0A42BC5E43E00676489 /* OnboardingUITests.swift */,
);
path = ENGAGEHFUITests;
sourceTree = "<group>";
Expand Down Expand Up @@ -361,6 +372,7 @@
A92E4DEF2BAA001100AC8DE8 /* OrderedCollections */,
56E708342BB06B7100B08F0A /* SpeziLicense */,
56E7083A2BB06F6F00B08F0A /* SwiftPackageList */,
4DBDD3472BC073EF001FB0CA /* FirebaseFunctions */,
);
productName = ENGAGEHF;
productReference = 653A254D283387FE005D4D48 /* ENGAGEHF.app */;
Expand Down Expand Up @@ -509,6 +521,7 @@
2FE5DC3829EDD7CA004B9AB4 /* InterestingModules.swift in Sources */,
2FE5DC3529EDD7CA004B9AB4 /* Consent.swift in Sources */,
2FE5DC4529EDD7F2004B9AB4 /* Binding+Negate.swift in Sources */,
4DBDD3442BBFAD64001FB0CA /* InvitationCodeError.swift in Sources */,
2FC975A82978F11A00BA99FE /* Home.swift in Sources */,
4DB025D52BBF2E08002D2545 /* Dashboard.swift in Sources */,
A9DFE8A92ABE551400428242 /* AccountButton.swift in Sources */,
Expand All @@ -526,6 +539,7 @@
2FE5DC3629EDD7CA004B9AB4 /* HealthKitPermissions.swift in Sources */,
2F65B44E2A3B8B0600A36932 /* NotificationPermissions.swift in Sources */,
2FE5DC2629EDD38A004B9AB4 /* Contacts.swift in Sources */,
4DBDD3462BBFAE2D001FB0CA /* InvitationCodeView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand All @@ -541,6 +555,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
4D4AA0A52BC5E43E00676489 /* OnboardingUITests.swift in Sources */,
2F4E237E2989A2FE0013F3D9 /* LaunchTests.swift in Sources */,
4DB025CA2BBE3A59002D2545 /* HomeViewUITests.swift in Sources */,
);
Expand Down Expand Up @@ -1264,6 +1279,11 @@
package = 2FE5DC9A29EDD9EF004B9AB4 /* XCRemoteSwiftPackageReference "XCTHealthKit" */;
productName = XCTHealthKit;
};
4DBDD3472BC073EF001FB0CA /* FirebaseFunctions */ = {
isa = XCSwiftPackageProductDependency;
package = 2FE5DC9029EDD9C3004B9AB4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
productName = FirebaseFunctions;
};
56E708342BB06B7100B08F0A /* SpeziLicense */ = {
isa = XCSwiftPackageProductDependency;
package = 56E708332BB06B7100B08F0A /* XCRemoteSwiftPackageReference "SpeziLicense" */;
Expand Down
6 changes: 3 additions & 3 deletions ENGAGEHF.xcodeproj/xcshareddata/xcschemes/ENGAGEHF.xcscheme
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,11 @@
<CommandLineArguments>
<CommandLineArgument
argument = "--disableFirebase"
isEnabled = "YES">
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "--showOnboarding"
isEnabled = "NO">
isEnabled = "YES">
</CommandLineArgument>
<CommandLineArgument
argument = "--skipOnboarding"
Expand All @@ -95,7 +95,7 @@
</CommandLineArgument>
<CommandLineArgument
argument = "--useFirebaseEmulator"
isEnabled = "NO">
isEnabled = "YES">
</CommandLineArgument>
</CommandLineArguments>
</LaunchAction>
Expand Down
4 changes: 4 additions & 0 deletions ENGAGEHF/Onboarding/InterestingModules.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ struct InterestingModules: View {
SequentialOnboardingView.Content(
title: "INTERESTING_MODULES_AREA4_TITLE",
description: "INTERESTING_MODULES_AREA4_DESCRIPTION"
),
SequentialOnboardingView.Content(
title: "INTERESTING_MODULES_AREA5_TITLE",
description: "INTERESTING_MODULES_AREA5_DESCRIPTION"
)
],
actionText: "INTERESTING_MODULES_BUTTON",
Expand Down
30 changes: 30 additions & 0 deletions ENGAGEHF/Onboarding/InvitationCodeError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//
// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project
//
// SPDX-FileCopyrightText: 2023 Stanford University
//
// SPDX-License-Identifier: MIT
//
// Based on: https://github.com/StanfordBDHG/PediatricAppleWatchStudy/pull/54/files
//

import Foundation


enum InvitationCodeError: LocalizedError {
case invitationCodeInvalid
case userNotAuthenticated
case generalError(String)


var errorDescription: String? {
switch self {
case .invitationCodeInvalid:
String(localized: "The invitation code is invalid or has already been used.", comment: "Invitation Code Invalid")
case .userNotAuthenticated:
String(localized: "User authentication failed. Please try to sign in again.", comment: "User Not Authenticated")
case .generalError(let message):
String(localized: "An error occurred: \(message)", comment: "General Error")
}
}
}
157 changes: 157 additions & 0 deletions ENGAGEHF/Onboarding/InvitationCodeView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
//
// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project
//
// SPDX-FileCopyrightText: 2023 Stanford University
//
// SPDX-License-Identifier: MIT
//

import Firebase
import FirebaseAuth
import FirebaseFunctions
import SpeziOnboarding
import SpeziValidation
import SpeziViews
import SwiftUI


struct InvitationCodeView: View {
@Environment(OnboardingNavigationPath.self) private var onboardingNavigationPath
@State private var invitationCode = ""
@State private var viewState: ViewState = .idle
@ValidationState private var validation


var body: some View {
ScrollView {
VStack(spacing: 32) {
invitationCodeHeader
Divider()
Grid(horizontalSpacing: 16, verticalSpacing: 16) {
invitationCodeView
}
.padding(.top, -8)
.padding(.bottom, -12)
Divider()
OnboardingActionsView(
primaryText: "Redeem Invitation Code",
primaryAction: {
guard validation.validateSubviews() else {
return
}

await verifyOnboardingCode()
},
secondaryText: "I Already Have an Account",
secondaryAction: {
try Auth.auth().signOut()
onboardingNavigationPath.nextStep()
}
)
}
.padding(.horizontal)
.padding(.bottom)
.viewStateAlert(state: $viewState)
.navigationBarTitleDisplayMode(.large)
.navigationTitle(String(localized: "Invitation Code"))
}
}


@ViewBuilder private var invitationCodeView: some View {
DescriptionGridRow {
Text("Invitation Code")
} content: {
VerifiableTextField(
LocalizedStringResource("Invitation Code"),
text: $invitationCode
)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.characters)
.textContentType(.oneTimeCode)
.validate(input: invitationCode, rules: [invitationCodeValidationRule])
}
.receiveValidation(in: $validation)
}

@ViewBuilder private var invitationCodeHeader: some View {
VStack(spacing: 32) {
Image(systemName: "rectangle.and.pencil.and.ellipsis")
.resizable()
.scaledToFit()
.frame(height: 100)
.accessibilityHidden(true)
.foregroundStyle(Color.accentColor)
Text("Please enter your invitation code to join the ENGAGE-HF study.")
}
}

private var invitationCodeValidationRule: ValidationRule {
ValidationRule(
rule: { invitationCode in
invitationCode.count >= 8
},
message: "An invitation code is at least 8 characters long."
)
}

init() {
if FeatureFlags.useFirebaseEmulator {
Functions.functions().useEmulator(withHost: "localhost", port: 5001)
}
}

private func verifyOnboardingCode() async {
do {
if FeatureFlags.disableFirebase {
guard invitationCode == "VASCTRAC" else {
throw InvitationCodeError.invitationCodeInvalid
}

try? await Task.sleep(for: .seconds(0.25))
} else {
try Auth.auth().signOut()

async let authResult = Auth.auth().signInAnonymously()
let checkInvitationCode = Functions.functions().httpsCallable("checkInvitationCode")

do {
_ = try await checkInvitationCode.call(
[
"invitationCode": invitationCode,
"userId": authResult.user.uid
]
)
} catch {
throw InvitationCodeError.invitationCodeInvalid
}
}

await onboardingNavigationPath.nextStep()
} catch let error as NSError {
if let errorCode = FunctionsErrorCode(rawValue: error.code) {
// Handle Firebase-specific errors.
switch errorCode {
case .unauthenticated:
viewState = .error(InvitationCodeError.userNotAuthenticated)
case .notFound:
viewState = .error(InvitationCodeError.invitationCodeInvalid)
default:
viewState = .error(InvitationCodeError.generalError(error.localizedDescription))
}
} else {
// Handle other errors, such as network issues or unexpected behavior.
viewState = .error(InvitationCodeError.generalError(error.localizedDescription))
}
}
}
}


#Preview {
FirebaseApp.configure()

return OnboardingStack {
InvitationCodeView()
}
}
1 change: 1 addition & 0 deletions ENGAGEHF/Onboarding/OnboardingFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ struct OnboardingFlow: View {
InterestingModules()

if !FeatureFlags.disableFirebase {
InvitationCodeView()
AccountOnboarding()
}

Expand Down
6 changes: 3 additions & 3 deletions ENGAGEHF/Onboarding/Welcome.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,23 +21,23 @@ struct Welcome: View {
areas: [
OnboardingInformationView.Content(
icon: {
Image(systemName: "apps.iphone")
Image(systemName: "person.3.fill")
.accessibilityHidden(true)
},
title: "WELCOME_AREA1_TITLE",
description: "WELCOME_AREA1_DESCRIPTION"
),
OnboardingInformationView.Content(
icon: {
Image(systemName: "shippingbox.fill")
Image(systemName: "list.bullet.clipboard.fill")
.accessibilityHidden(true)
},
title: "WELCOME_AREA2_TITLE",
description: "WELCOME_AREA2_DESCRIPTION"
),
OnboardingInformationView.Content(
icon: {
Image(systemName: "list.bullet.clipboard.fill")
Image(systemName: "waveform.path.ecg")
.accessibilityHidden(true)
},
title: "WELCOME_AREA3_TITLE",
Expand Down
5 changes: 4 additions & 1 deletion ENGAGEHF/Resources/ConsentDocument.md
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
Spezi can render consent documents in the markdown format: This is a *markdown* **example**.
The ENGAGE-HF iOS Mobile Application will connect to external devices via Bluetooth
to record personal health information, including weight, heart rate, and blood pressure.

Your personal information will only be shared with the research team conducting the study.
Loading

0 comments on commit 15b47f6

Please sign in to comment.