Skip to content

Commit

Permalink
Invitation Codes (#54)
Browse files Browse the repository at this point in the history
# Invitation Codes

## ♻️ Current situation & Problem
There is a new collection in Firestore called invitation code bucket
called `invitationCodes`. During onboarding, we sign the user in
anonymously so we can check if they have a valid code. If they do, we
assign that code to their new de-anonymized account and remove it from
`invitationCodes` so it cannot be used again. Some kind of verification
step like this hedges against spam and unapproved use.


## ⚙️ Release Notes 
- Adds an authentication step to onboarding whereby the user confirms
against valid Firebase invitation codes that they are enrolled in the
study.


## 📚 Documentation
See [Firebase
documentation](https://firebase.google.com/docs/auth/web/anonymous-auth).


## ✅ Testing
TBD.


### 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
MatthewTurk247 and PSchmiedmayer authored Apr 7, 2024
1 parent dfa0f6c commit 0859e0e
Show file tree
Hide file tree
Showing 26 changed files with 7,364 additions and 19 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 @@ -38,7 +38,7 @@ jobs:
artifactname: PAWS.xcresult
runsonlabels: '["macOS", "self-hosted"]'
setupfirebaseemulator: true
customcommand: "firebase emulators:exec 'fastlane test'"
customcommand: "npm install --prefix ./functions && firebase emulators:exec --import=./firebase 'fastlane test'"
uploadcoveragereport:
name: Upload Coverage Report
needs: buildandtest
Expand Down
5 changes: 4 additions & 1 deletion .reuse/dep5
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,7 @@ Copyright: 2024 Stanford University and the project authors (see CONTRIBUTORS.md
License: MIT
Comment: All files are part of the Stanford Spezi Data Pipeline Template open source project.


Files: firebase/*
Copyright: 2024 Stanford University and the project authors (see CONTRIBUTORS.md)
License: MIT
Comment: All files are part of the PAWS application based on the Stanford Spezi Template Application project.
24 changes: 22 additions & 2 deletions PAWS.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@
A9DFE8A92ABE551400428242 /* AccountButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DFE8A82ABE551400428242 /* AccountButton.swift */; };
A9FE7AD02AA39BAB0077B045 /* AccountSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FE7ACF2AA39BAB0077B045 /* AccountSheet.swift */; };
B28A0DDC2BA4AEDE0068258D /* Date+Bool.swift in Sources */ = {isa = PBXBuildFile; fileRef = B28A0DDB2BA4AEDE0068258D /* Date+Bool.swift */; };
B2DB1AD32BB4BCB100B0F49B /* AccountCreationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2DB1AD22BB4BCB100B0F49B /* AccountCreationTests.swift */; };
B2F7F1DC2BA53F6400BE93BE /* InvitationCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2F7F1DB2BA53F6400BE93BE /* InvitationCodeView.swift */; };
B2F7F1DF2BA540BB00BE93BE /* FirebaseFunctions in Frameworks */ = {isa = PBXBuildFile; productRef = B2F7F1DE2BA540BB00BE93BE /* FirebaseFunctions */; };
B2F7F1E22BA549A900BE93BE /* InvitationCodeError.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2F7F1E12BA549A900BE93BE /* InvitationCodeError.swift */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -145,6 +149,9 @@
A9DFE8A82ABE551400428242 /* AccountButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountButton.swift; sourceTree = "<group>"; };
A9FE7ACF2AA39BAB0077B045 /* AccountSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSheet.swift; sourceTree = "<group>"; };
B28A0DDB2BA4AEDE0068258D /* Date+Bool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Bool.swift"; sourceTree = "<group>"; };
B2DB1AD22BB4BCB100B0F49B /* AccountCreationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountCreationTests.swift; sourceTree = "<group>"; };
B2F7F1DB2BA53F6400BE93BE /* InvitationCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitationCodeView.swift; sourceTree = "<group>"; };
B2F7F1E12BA549A900BE93BE /* InvitationCodeError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitationCodeError.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand All @@ -169,6 +176,7 @@
2FF53D8B2A8725DE00042B76 /* SpeziMockWebService in Frameworks */,
2FE5DC7229EDD8D3004B9AB4 /* SpeziHealthKit in Frameworks */,
2F49B7762980407C00BCB272 /* Spezi in Frameworks */,
B2F7F1DF2BA540BB00BE93BE /* FirebaseFunctions in Frameworks */,
2FE5DC8F29EDD980004B9AB4 /* SpeziViews in Frameworks */,
2F3D4ABC2A4E7C290068FB2F /* SpeziScheduler in Frameworks */,
2FBD738C2A3BD150004228E7 /* SpeziScheduler in Frameworks */,
Expand Down Expand Up @@ -239,6 +247,8 @@
2FE5DC3129EDD7CA004B9AB4 /* OnboardingFlow.swift */,
2FE5DC3429EDD7CA004B9AB4 /* Welcome.swift */,
2FE5DC3229EDD7CA004B9AB4 /* InterestingModules.swift */,
B2F7F1DB2BA53F6400BE93BE /* InvitationCodeView.swift */,
B2F7F1E12BA549A900BE93BE /* InvitationCodeError.swift */,
2FE5DCAC29EE6107004B9AB4 /* AccountOnboarding.swift */,
2FE5DC2F29EDD7CA004B9AB4 /* Consent.swift */,
2FE5DC3029EDD7CA004B9AB4 /* HealthKitPermissions.swift */,
Expand Down Expand Up @@ -367,6 +377,7 @@
isa = PBXGroup;
children = (
2F4E237D2989A2FE0013F3D9 /* LaunchTests.swift */,
B2DB1AD22BB4BCB100B0F49B /* AccountCreationTests.swift */,
);
path = PAWSUITests;
sourceTree = "<group>";
Expand Down Expand Up @@ -429,6 +440,7 @@
9739A0C52AD7B5730084BEA5 /* FirebaseStorage */,
97D73D692AD860AD00B47FA0 /* SpeziFirebaseStorage */,
A9D83F952B083794000D0C78 /* SpeziFirebaseAccountStorage */,
B2F7F1DE2BA540BB00BE93BE /* FirebaseFunctions */,
);
productName = PAWS;
productReference = 653A254D283387FE005D4D48 /* PAWS.app */;
Expand Down Expand Up @@ -587,6 +599,7 @@
2FE5DC4729EDD7F2004B9AB4 /* CodableArray+RawRepresentable.swift in Sources */,
A9720E432ABB68CC00872D23 /* AccountSetupHeader.swift in Sources */,
2FE5DC4029EDD7EE004B9AB4 /* FeatureFlags.swift in Sources */,
B2F7F1DC2BA53F6400BE93BE /* InvitationCodeView.swift in Sources */,
2FE5DC4629EDD7F2004B9AB4 /* Bundle+Image.swift in Sources */,
2FFD22FC2B59AED8005DD268 /* FAQ.swift in Sources */,
2F4E23832989D51F0013F3D9 /* PAWSTestingSetup.swift in Sources */,
Expand All @@ -601,6 +614,7 @@
A9FE7AD02AA39BAB0077B045 /* AccountSheet.swift in Sources */,
653A2551283387FE005D4D48 /* PAWS.swift in Sources */,
2FFD22F62B59ABE2005DD268 /* PAWSCard.swift in Sources */,
B2F7F1E22BA549A900BE93BE /* InvitationCodeError.swift in Sources */,
2FE5DC3629EDD7CA004B9AB4 /* HealthKitPermissions.swift in Sources */,
2F65B44E2A3B8B0600A36932 /* NotificationPermissions.swift in Sources */,
5661552E2AB854C000209B80 /* PackageHelper.swift in Sources */,
Expand All @@ -621,6 +635,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B2DB1AD32BB4BCB100B0F49B /* AccountCreationTests.swift in Sources */,
2F4E237E2989A2FE0013F3D9 /* LaunchTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down Expand Up @@ -1157,7 +1172,7 @@
repositoryURL = "https://github.com/StanfordBDHG/HealthKitOnFHIR.git";
requirement = {
kind = upToNextMinorVersion;
minimumVersion = 0.2.4;
minimumVersion = 0.2.7;
};
};
2FCC1DCE2B6A2CE000C686BE /* XCRemoteSwiftPackageReference "SwiftLint" */ = {
Expand Down Expand Up @@ -1197,7 +1212,7 @@
repositoryURL = "https://github.com/StanfordSpezi/SpeziFirebase.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.0.0;
minimumVersion = 1.1.0;
};
};
2FE5DC8829EDD972004B9AB4 /* XCRemoteSwiftPackageReference "SpeziStorage" */ = {
Expand Down Expand Up @@ -1396,6 +1411,11 @@
package = 2FE5DC7329EDD8E6004B9AB4 /* XCRemoteSwiftPackageReference "SpeziFirebase" */;
productName = SpeziFirebaseAccountStorage;
};
B2F7F1DE2BA540BB00BE93BE /* FirebaseFunctions */ = {
isa = XCSwiftPackageProductDependency;
package = 2FE5DC9029EDD9C3004B9AB4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
productName = FirebaseFunctions;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 653A2545283387FE005D4D48 /* Project object */;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/StanfordBDHG/HealthKitOnFHIR.git",
"state" : {
"revision" : "00d64d38a8f0d826ee9e27b6f3ce32314a29fd3e",
"version" : "0.2.6"
"revision" : "d6ceecf11800d73fed0c6ce33717f3dc71a44bd7",
"version" : "0.2.7"
}
},
{
Expand Down Expand Up @@ -168,8 +168,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/StanfordSpezi/SpeziAccount.git",
"state" : {
"revision" : "a7d289ef3be54de62b25dc92e8f7ff1a0f093906",
"version" : "1.2.1"
"revision" : "cb9441e5fe9ca31a17be2507d03817a080e63e9d",
"version" : "1.2.2"
}
},
{
Expand All @@ -186,8 +186,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/StanfordSpezi/SpeziFirebase.git",
"state" : {
"revision" : "e05e665b7da39aa399ecd7fba393aab49b8f3034",
"version" : "1.0.1"
"revision" : "16c1c751c14b08ae593eacf9bc2752c2e070fe2f",
"version" : "1.1.0"
}
},
{
Expand Down
2 changes: 1 addition & 1 deletion PAWS/ECGRecordings/ECGRecording.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ struct ECGRecording: View {
var body: some View {
PAWSCard {
VStack(alignment: .leading) {
Text("EEG Recording")
Text("ECG Recording")
.font(.title)
Text(electrocardiogram.endDate.formatted())
.font(.subheadline)
Expand Down
27 changes: 27 additions & 0 deletions PAWS/Onboarding/InvitationCodeError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//
// This source file is part of the PAWS application based on the Stanford Spezi Template Application project
//
// SPDX-FileCopyrightText: 2023 Stanford University
//
// SPDX-License-Identifier: MIT
//

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 PAWS/Onboarding/InvitationCodeView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
//
// This source file is part of the PAWS application 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 PAWS 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 {
if Auth.auth().currentUser == nil {
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 PAWS/Onboarding/OnboardingFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ struct OnboardingFlow: View {
InterestingModules()

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

Expand Down
5 changes: 0 additions & 5 deletions PAWS/PAWSStandard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -231,11 +231,6 @@ actor PAWSStandard: Standard, EnvironmentAccessible, HealthKitConstraint, Onboar
guard let accountStorage else {
preconditionFailure("Account Storage was requested although not enabled in current configuration.")
}
if let dob = details.dateOfBrith {
// Store whether the participant is older or younger than 18.
try await userDocumentReference.getDocument().setValue(dob.isAdultDateOfBirth, forKey: "ageGroupIsAdult")
}

try await accountStorage.create(identifier, details)
}

Expand Down
Loading

0 comments on commit 0859e0e

Please sign in to comment.