diff --git a/apple/Entitlements/Omnivore.entitlements b/apple/Entitlements/Omnivore.entitlements index b53defce6..270cdb0d4 100644 --- a/apple/Entitlements/Omnivore.entitlements +++ b/apple/Entitlements/Omnivore.entitlements @@ -8,6 +8,16 @@ Default + com.apple.developer.associated-domains + + applinks:omnivore.app + applinks:dev.omnivore.app + applinks:demo.omnivore.app + webcredentials:omnivore.app + webcredentials:dev.omnivore.app + + com.apple.developer.authentication-services.autofill-credential-provider + com.apple.security.application-groups group.app.omnivoreapp @@ -16,11 +26,5 @@ $(AppIdentifierPrefix)app.omnivore.shared - com.apple.developer.associated-domains - - applinks:omnivore.app - applinks:dev.omnivore.app - applinks:demo.omnivore.app - diff --git a/apple/Omnivore.xcodeproj/project.pbxproj b/apple/Omnivore.xcodeproj/project.pbxproj index 97423c746..fff87604d 100644 --- a/apple/Omnivore.xcodeproj/project.pbxproj +++ b/apple/Omnivore.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 040333C9288F50B5008F0BDF /* AuthenticationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 040333C8288F50B5008F0BDF /* AuthenticationServices.framework */; }; 0411792A26A22860004AE24F /* MacAppSmokeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0411792926A22860004AE24F /* MacAppSmokeTest.swift */; }; 0418837E2742E99F003E0001 /* Intercom in Frameworks */ = {isa = PBXBuildFile; productRef = 0418837D2742E99F003E0001 /* Intercom */; }; 041883802742FCF2003E0001 /* Utils in Frameworks */ = {isa = PBXBuildFile; productRef = 0418837F2742FCF2003E0001 /* Utils */; }; @@ -174,6 +175,7 @@ /* Begin PBXFileReference section */ 03BE831A6CCC63104CB366AE /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 040333C8288F50B5008F0BDF /* AuthenticationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AuthenticationServices.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.4.sdk/System/Library/Frameworks/AuthenticationServices.framework; sourceTree = DEVELOPER_DIR; }; 0411792726A22860004AE24F /* UnitTests-MacApp.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "UnitTests-MacApp.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 0411792926A22860004AE24F /* MacAppSmokeTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacAppSmokeTest.swift; sourceTree = ""; }; 0411792B26A22860004AE24F /* MacUnitTests.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = MacUnitTests.plist; sourceTree = ""; }; @@ -302,6 +304,7 @@ buildActionMask = 2147483647; files = ( D2BC24479582C1B97F67646A /* App in Frameworks */, + 040333C9288F50B5008F0BDF /* AuthenticationServices.framework in Frameworks */, 0418837E2742E99F003E0001 /* Intercom in Frameworks */, 045B1681279147E7005047F7 /* FirebaseMessaging in Frameworks */, 041883802742FCF2003E0001 /* Utils in Frameworks */, @@ -423,6 +426,7 @@ 4214F6EE2714D2260096B644 /* Frameworks */ = { isa = PBXGroup; children = ( + 040333C8288F50B5008F0BDF /* AuthenticationServices.framework */, ); name = Frameworks; sourceTree = ""; @@ -1229,7 +1233,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 66; + CURRENT_PROJECT_VERSION = 68; DEVELOPMENT_TEAM = QJF2XZ86HB; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = InfoPlists/ShareExtensionMac.plist; @@ -1239,7 +1243,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.12.1; + MARKETING_VERSION = 1.13.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.ShareExtension-Mac"; @@ -1261,7 +1265,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 66; + CURRENT_PROJECT_VERSION = 68; DEVELOPMENT_TEAM = QJF2XZ86HB; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = InfoPlists/ShareExtensionMac.plist; @@ -1271,7 +1275,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.12.1; + MARKETING_VERSION = 1.13.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.ShareExtension-Mac"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1343,7 +1347,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 66; + CURRENT_PROJECT_VERSION = 68; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = QJF2XZ86HB; ENABLE_HARDENED_RUNTIME = YES; @@ -1354,7 +1358,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.12.1; + MARKETING_VERSION = 1.13.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app; @@ -1377,7 +1381,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 66; + CURRENT_PROJECT_VERSION = 68; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = QJF2XZ86HB; ENABLE_HARDENED_RUNTIME = YES; @@ -1388,7 +1392,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.12.1; + MARKETING_VERSION = 1.13.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1443,7 +1447,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.12.1; + MARKETING_VERSION = 1.13.0; PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app; PRODUCT_NAME = Omnivore; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1475,7 +1479,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.12.1; + MARKETING_VERSION = 1.13.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ( @@ -1514,7 +1518,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.12.1; + MARKETING_VERSION = 1.13.0; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ( "-framework", @@ -1540,7 +1544,7 @@ CODE_SIGN_ENTITLEMENTS = "Entitlements/SafariExtension-Mac.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 66; + CURRENT_PROJECT_VERSION = 68; DEVELOPMENT_TEAM = QJF2XZ86HB; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1553,7 +1557,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.12.1; + MARKETING_VERSION = 1.13.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ( @@ -1579,7 +1583,7 @@ CODE_SIGN_ENTITLEMENTS = "Entitlements/SafariExtension-Mac.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 66; + CURRENT_PROJECT_VERSION = 68; DEVELOPMENT_TEAM = QJF2XZ86HB; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1592,7 +1596,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.12.1; + MARKETING_VERSION = 1.13.0; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ( "-framework", @@ -1679,7 +1683,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.12.1; + MARKETING_VERSION = 1.13.0; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension"; PRODUCT_NAME = ShareExtension; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1734,7 +1738,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.12.1; + MARKETING_VERSION = 1.13.0; PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app; PRODUCT_NAME = Omnivore; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1763,7 +1767,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.12.1; + MARKETING_VERSION = 1.13.0; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension"; PRODUCT_NAME = ShareExtension; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/apple/OmnivoreKit/Sources/App/Views/Registration/CreateProfileView.swift b/apple/OmnivoreKit/Sources/App/Views/Registration/CreateProfileView.swift index d06681338..4164550fb 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Registration/CreateProfileView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Registration/CreateProfileView.swift @@ -224,7 +224,7 @@ struct CreateProfileView: View { } } -private extension PotentialUsernameStatus { +extension PotentialUsernameStatus { var message: String? { switch self { case .tooShort: diff --git a/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuth/EmailAuthView.swift b/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuth/EmailAuthView.swift new file mode 100644 index 000000000..0a5905809 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuth/EmailAuthView.swift @@ -0,0 +1,63 @@ +import Combine +import Models +import Services +import SwiftUI +import Utils +import Views + +enum EmailAuthState { + case signIn + case signUp + case loading + case pendingEmailVerification(email: String, password: String) +} + +@MainActor final class EmailAuthViewModel: ObservableObject { + @Published var loginError: LoginError? + @Published var emailAuthState = EmailAuthState.signIn + @Published var potentialUsernameStatus = PotentialUsernameStatus.noUsername + @Published var potentialUsername = "" + + var subscriptions = Set() +} + +struct EmailAuthView: View { + @Environment(\.presentationMode) private var presentationMode + @StateObject private var viewModel = EmailAuthViewModel() + + @ViewBuilder var primaryContent: some View { + switch viewModel.emailAuthState { + case .signUp: + EmailSignupFormView(viewModel: viewModel) + case .signIn: + EmailLoginFormView(viewModel: viewModel) + case let .pendingEmailVerification(email, password): + EmailPendingVerificationView(email: email, password: password, viewModel: viewModel) + case .loading: + VStack { + Spacer() + ProgressView() + Spacer() + } + } + } + + var body: some View { + NavigationView { + ZStack { + Color.appBackground.edgesIgnoringSafeArea(.all) + primaryContent + .frame(maxWidth: 300) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .barTrailing) { + Button( + action: { presentationMode.wrappedValue.dismiss() }, + label: { Image(systemName: "xmark").foregroundColor(.appGrayTextContrast) } + ) + } + } + } + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuth/EmailLoginFormView.swift b/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuth/EmailLoginFormView.swift new file mode 100644 index 000000000..eceda710a --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuth/EmailLoginFormView.swift @@ -0,0 +1,187 @@ +import Models +import Services +import SwiftUI +import Utils +import Views + +extension EmailAuthViewModel { + func submitCredentials( + email: String, + password: String, + authenticator: Authenticator + ) async { + do { + try await authenticator.submitEmailLogin(email: email, password: password) + } catch { + if let newLoginError = error as? LoginError { + if newLoginError == .pendingEmailVerification { + emailAuthState = .pendingEmailVerification(email: email, password: password) + } else { + loginError = newLoginError + } + } + } + } +} + +struct EmailLoginFormView: View { + enum FocusedField { + case email, password + } + + @Environment(\.horizontalSizeClass) var horizontalSizeClass + @Environment(\.openURL) var openURL + @EnvironmentObject var authenticator: Authenticator + @ObservedObject var viewModel: EmailAuthViewModel + + @FocusState private var focusedField: FocusedField? + @State private var email = "" + @State private var password = "" + + var body: some View { + VStack(spacing: 0) { + VStack(spacing: 28) { + ScrollView(showsIndicators: false) { + if horizontalSizeClass == .regular { + Spacer(minLength: 150) + } + VStack { + VStack(alignment: .leading, spacing: 6) { + Text("Email") + .font(.appFootnote) + .foregroundColor(.appGrayText) + TextField("", text: $email) + .keyboardType(.emailAddress) + .textContentType(.emailAddress) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .focused($focusedField, equals: .email) + .submitLabel(.next) + } + .padding(.bottom, 8) + + VStack(alignment: .leading, spacing: 6) { + Text("Password") + .font(.appFootnote) + .foregroundColor(.appGrayText) + SecureField("", text: $password) + .textContentType(.password) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .focused($focusedField, equals: .password) + .submitLabel(.done) + } + .padding(.bottom, 16) + + Button( + action: { + Task { + await viewModel.submitCredentials( + email: email, + password: password, + authenticator: authenticator + ) + } + }, + label: { Text("Submit") } + ) + .buttonStyle(SolidCapsuleButtonStyle(color: .appDeepBackground, width: 300)) + + if let loginError = viewModel.loginError { + LoginErrorMessageView(loginError: loginError) + } + + VStack(spacing: 0) { + HStack { + Button( + action: { viewModel.emailAuthState = .signUp }, + label: { + Text("Don't have an account?") + .foregroundColor(.appGrayTextContrast) + .underline() + } + ) + .padding(.vertical, 8) + Spacer() + } + + HStack { + Button( + action: { + openURL(URL(string: "https://omnivore.app/auth/forgot-password")!) + }, + label: { + Text("Forgot your password?") + .foregroundColor(.appGrayTextContrast) + .underline() + } + ) + .padding(.vertical, 8) + Spacer() + } + } + } + .textFieldStyle(StandardTextFieldStyle()) + .onSubmit { + if focusedField == .email { + focusedField = .password + } else { + focusedField = nil + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + Spacer() + } + } + .navigationTitle(focusedField == nil ? "Sign In" : "") + } +} + +struct EmailPendingVerificationView: View { + let email: String + let password: String + + @ObservedObject var viewModel: EmailAuthViewModel + @EnvironmentObject var authenticator: Authenticator + + var verificationMessage: String { + "We've sent a verification email to \(email). Please verify your email and then tap the button below." + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(verificationMessage) + .font(.appBody) + + Button( + action: { + Task { + await viewModel.submitCredentials( + email: email, + password: password, + authenticator: authenticator + ) + } + }, + label: { Text("Check Status") } + ) + .buttonStyle(SolidCapsuleButtonStyle(color: .appDeepBackground, width: 300)) + + HStack { + Button( + action: { viewModel.emailAuthState = .signUp }, + label: { + Text("Use a different email?") + .foregroundColor(.appGrayTextContrast) + .underline() + } + ) + .padding(.vertical) + Spacer() + } + } + .navigationTitle("Verify Email") + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuth/EmailSignupFormView.swift b/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuth/EmailSignupFormView.swift new file mode 100644 index 000000000..4c25b8305 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuth/EmailSignupFormView.swift @@ -0,0 +1,221 @@ +import Combine +import Models +import Services +import SwiftUI +import Utils +import Views + +extension EmailAuthViewModel { + func signUp( + email: String, + password: String, + fullName: String, + authenticator: Authenticator + ) async { + do { + try await authenticator.submitUserSignUp( + email: email, + password: password, + username: potentialUsername, + name: fullName + ) + emailAuthState = .pendingEmailVerification(email: email, password: password) + } catch { + loginError = error as? LoginError + } + } + + func validateUsername(username: String, dataService: DataService) { + if let status = PotentialUsernameStatus.validationError(username: username.lowercased()) { + potentialUsernameStatus = status + return + } + + Task { + do { + try await dataService.validateUsernamePublisher(username: username) + potentialUsernameStatus = .available + } catch { + let usernameError = (error as? UsernameAvailabilityError) ?? .unknown + switch usernameError { + case .tooShort: + potentialUsernameStatus = .tooShort + case .tooLong: + potentialUsernameStatus = .tooLong + case .invalidPattern: + potentialUsernameStatus = .invalidPattern + case .nameUnavailable: + potentialUsernameStatus = .unavailable + case .internalServer, .unknown: + loginError = .unknown + case .network: + loginError = .network + } + } + } + } + + func configureUsernameValidation(dataService: DataService) { + $potentialUsername + .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main) + .sink(receiveValue: { [weak self] username in + self?.validateUsername(username: username, dataService: dataService) + }) + .store(in: &subscriptions) + } +} + +struct EmailSignupFormView: View { + enum FocusedField { + case email, password, fullName, username + } + + @Environment(\.horizontalSizeClass) var horizontalSizeClass + @EnvironmentObject var authenticator: Authenticator + @EnvironmentObject var dataService: DataService + @ObservedObject var viewModel: EmailAuthViewModel + + @FocusState private var focusedField: FocusedField? + @State private var email = "" + @State private var password = "" + @State private var name = "" + @State private var username = "" + + var body: some View { + VStack(spacing: 0) { + VStack(spacing: 28) { + ScrollView(showsIndicators: false) { + if horizontalSizeClass == .regular { + Spacer(minLength: 150) + } + VStack { + // Email + VStack(alignment: .leading, spacing: 6) { + Text("Email") + .font(.appFootnote) + .foregroundColor(.appGrayText) + TextField("", text: $email) + .focused($focusedField, equals: .email) + .textContentType(.emailAddress) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .submitLabel(.next) + } + .padding(.bottom, 8) + + // Password + VStack(alignment: .leading, spacing: 6) { + Text("Password") + .font(.appFootnote) + .foregroundColor(.appGrayText) + SecureField("", text: $password) + .focused($focusedField, equals: .password) + .textContentType(.newPassword) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .submitLabel(.next) + } + .padding(.bottom, 8) + + // Full Name + VStack(alignment: .leading, spacing: 6) { + Text("Full Name") + .font(.appFootnote) + .foregroundColor(.appGrayText) + TextField("", text: $name) + .focused($focusedField, equals: .fullName) + .textContentType(.name) + .keyboardType(.alphabet) + .disableAutocorrection(true) + .submitLabel(.next) + } + .padding(.bottom, 8) + + // Username + VStack(alignment: .leading, spacing: 6) { + HStack { + VStack(alignment: .leading, spacing: 6) { + Text("Username") + .font(.appFootnote) + .foregroundColor(.appGrayText) + TextField("", text: $viewModel.potentialUsername) + .focused($focusedField, equals: .username) + .textInputAutocapitalization(.never) + .textContentType(.username) + .disableAutocorrection(true) + .keyboardType(.alphabet) + .submitLabel(.done) + } + + if viewModel.potentialUsernameStatus == .available { + Image(systemName: "checkmark.circle.fill") + .font(.appBody) + .foregroundColor(.green) + } + } + if let message = viewModel.potentialUsernameStatus.message { + Text(message) + .font(.appCaption) + .foregroundColor(.red) + } + } + .padding(.bottom, 16) + .animation(.default, value: 0.35) + + Button( + action: { + Task { + await viewModel.signUp( + email: email, + password: password, + fullName: name, + authenticator: authenticator + ) + } + }, + label: { Text("Submit") } + ) + .buttonStyle(SolidCapsuleButtonStyle(color: .appDeepBackground, width: 300)) + + if let loginError = viewModel.loginError { + LoginErrorMessageView(loginError: loginError) + } + + HStack { + Button( + action: { viewModel.emailAuthState = .signIn }, + label: { + Text("Already have an account?") + .foregroundColor(.appGrayTextContrast) + .underline() + } + ) + .padding(.vertical) + Spacer() + } + } + .textFieldStyle(StandardTextFieldStyle()) + .onSubmit { + if focusedField == .email { + focusedField = .password + } else if focusedField == .password { + focusedField = .fullName + } else if focusedField == .fullName { + focusedField = .username + } else { + focusedField = nil + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + Spacer() + } + } + .navigationTitle(focusedField == nil ? "Sign Up" : "") + .task { + viewModel.configureUsernameValidation(dataService: dataService) + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Registration/RegistrationView.swift b/apple/OmnivoreKit/Sources/App/Views/Registration/RegistrationView.swift index f365a467e..8bd5754ab 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Registration/RegistrationView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Registration/RegistrationView.swift @@ -20,7 +20,7 @@ import Views Task { await handleAppleToken(payload: payload, authenticator: authenticator) } case let .failure(error): switch error { - case .unauthorized, .unknown: + case .unauthorized, .unknown, .pendingEmailVerification: break case .network: loginError = error @@ -34,7 +34,7 @@ import Views } catch { let submitTokenError = (error as? LoginError) ?? .unknown switch submitTokenError { - case .unauthorized, .unknown: + case .unauthorized, .unknown, .pendingEmailVerification: await handleAppleSignUp(authenticator: authenticator, payload: payload) case .network: loginError = submitTokenError diff --git a/apple/OmnivoreKit/Sources/App/Views/WelcomeView.swift b/apple/OmnivoreKit/Sources/App/Views/WelcomeView.swift index f7361e714..c3deec02a 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WelcomeView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WelcomeView.swift @@ -17,6 +17,7 @@ struct WelcomeView: View { @State private var showTermsLinks = false @State private var showTermsModal = false @State private var showPrivacyModal = false + @State private var showEmailLoginModal = false @State private var showAboutPage = false @State private var selectedEnvironment = AppEnvironment.initialAppEnvironment @State private var containerSize: CGSize = .zero @@ -138,11 +139,7 @@ struct WelcomeView: View { var authProviderButtonStack: some View { let useHorizontalLayout = containerSize.width > 500 - let buttonGroup = Group { - AppleSignInButton { - viewModel.handleAppleSignInCompletion(result: $0, authenticator: authenticator) - } - + let googleButton = Group { if AppKeys.sharedInstance?.iosClientGoogleId != nil { GoogleAuthButton { Task { @@ -152,12 +149,38 @@ struct WelcomeView: View { } } + let appleButton = AppleSignInButton { + viewModel.handleAppleSignInCompletion(result: $0, authenticator: authenticator) + } + + let emailButton = Button( + action: { showEmailLoginModal = true }, + label: { + Text("Continue with Email") + .foregroundColor(.appGrayTextContrast) + .underline() + } + ) + .padding(.vertical) + return VStack(alignment: .center, spacing: 16) { if useHorizontalLayout { - HStack { buttonGroup } + VStack(alignment: .leading, spacing: 0) { + HStack { + appleButton + googleButton + } + emailButton + } } else { - buttonGroup + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 16) { + appleButton + googleButton + } + emailButton + } } if let loginError = viewModel.loginError { @@ -206,6 +229,9 @@ struct WelcomeView: View { Spacer() } .padding() + .sheet(isPresented: $showEmailLoginModal) { + EmailAuthView() + } .sheet(isPresented: $showDebugModal) { DebugMenuView(selectedEnvironment: $selectedEnvironment) } diff --git a/apple/OmnivoreKit/Sources/Models/AppEnvironment.swift b/apple/OmnivoreKit/Sources/Models/AppEnvironment.swift index e49cb03f9..b6c94f86e 100644 --- a/apple/OmnivoreKit/Sources/Models/AppEnvironment.swift +++ b/apple/OmnivoreKit/Sources/Models/AppEnvironment.swift @@ -63,17 +63,4 @@ public extension AppEnvironment { return URL(string: "http://localhost:3000")! } } - - var highlightsServerBaseURL: URL { - switch self { - case .dev: - return URL(string: devHighlightsServerURL)! - case .demo: - return URL(string: demoHighlightsServerURL)! - case .prod: - return URL(string: prodHighlightsServerURL)! - case .test, .local: - return URL(string: "http://localhost:8080")! - } - } } diff --git a/apple/OmnivoreKit/Sources/Models/ErrorModels/LoginError.swift b/apple/OmnivoreKit/Sources/Models/ErrorModels/LoginError.swift index 8be887841..438fbb983 100644 --- a/apple/OmnivoreKit/Sources/Models/ErrorModels/LoginError.swift +++ b/apple/OmnivoreKit/Sources/Models/ErrorModels/LoginError.swift @@ -4,4 +4,5 @@ public enum LoginError: Error { case unauthorized case network case unknown + case pendingEmailVerification } diff --git a/apple/OmnivoreKit/Sources/Services/Authentication/AccountCreator.swift b/apple/OmnivoreKit/Sources/Services/Authentication/AccountCreator.swift index 9ef9d3321..73e31dbb7 100644 --- a/apple/OmnivoreKit/Sources/Services/Authentication/AccountCreator.swift +++ b/apple/OmnivoreKit/Sources/Services/Authentication/AccountCreator.swift @@ -45,3 +45,47 @@ extension Authenticator { } } } + +// email auth +public extension Authenticator { + func submitEmailLogin( + email: String, + password: String + ) async throws { + do { + let params = EmailSignInParams(email: email, password: password) + let emailAuthPayload = try await networker.submitEmailLogin(params: params) + + if let authCookieString = emailAuthPayload.authCookieString, let authToken = emailAuthPayload.authToken { + let authPayload = AuthPayload(authCookieString: authCookieString, authToken: authToken) + try ValetKey.authCookieString.setValue(authPayload.commentedAuthCookieString) + try ValetKey.authToken.setValue(authPayload.authToken) + DispatchQueue.main.async { + self.isLoggedIn = true + } + } else if emailAuthPayload.pendingEmailVerification == true { + throw ServerError.pendingEmailVerification + } else { + throw ServerError.unknown + } + } catch { + let serverError = (error as? ServerError) ?? ServerError.unknown + throw LoginError.make(serverError: serverError) + } + } + + func submitUserSignUp( + email: String, + password: String, + username: String, + name: String + ) async throws { + do { + let params = EmailSignUpParams(email: email, password: password, username: username, name: name) + try await networker.submitEmailSignUp(params: params) + } catch { + let serverError = (error as? ServerError) ?? ServerError.unknown + throw LoginError.make(serverError: serverError) + } + } +} diff --git a/apple/OmnivoreKit/Sources/Services/Authentication/AuthModels.swift b/apple/OmnivoreKit/Sources/Services/Authentication/AuthModels.swift index fafdee599..454e06b1e 100644 --- a/apple/OmnivoreKit/Sources/Services/Authentication/AuthModels.swift +++ b/apple/OmnivoreKit/Sources/Services/Authentication/AuthModels.swift @@ -18,6 +18,18 @@ struct SignInParams: Encodable { let provider: AuthProvider } +struct EmailSignInParams: Encodable { + let email: String + let password: String +} + +struct EmailSignUpParams: Encodable { + let email: String + let password: String + let username: String + let name: String +} + enum AuthProvider: String, Encodable { case apple = "APPLE" case google = "GOOGLE" @@ -28,6 +40,12 @@ struct AuthPayload: Decodable { let authToken: String } +struct EmailAuthPayload: Decodable { + let authCookieString: String? + let authToken: String? + let pendingEmailVerification: Bool? +} + struct CreateAccountParams: Encodable { let pendingUserToken: String let userProfile: UserProfile @@ -38,6 +56,10 @@ struct PendingUserAuthPayload: Decodable { let pendingUserProfile: UserProfile } +struct PendingEmailVerificationAuthPayload: Decodable { + let pendingEmailVerificationToken: String +} + extension AuthPayload { var commentedAuthCookieString: String { authCookieString.replacingOccurrences(of: "HttpOnly", with: "comment=ios-webview-cookie; HttpOnly") @@ -53,6 +75,8 @@ extension LoginError { return .unauthorized case .unknown: return .unknown + case .pendingEmailVerification: + return .pendingEmailVerification } } } diff --git a/apple/OmnivoreKit/Sources/Services/Authentication/GoogleAuth.swift b/apple/OmnivoreKit/Sources/Services/Authentication/GoogleAuth.swift index 9c3f081ab..2fa15f0f2 100644 --- a/apple/OmnivoreKit/Sources/Services/Authentication/GoogleAuth.swift +++ b/apple/OmnivoreKit/Sources/Services/Authentication/GoogleAuth.swift @@ -31,7 +31,7 @@ extension Authenticator { switch loginError { case .unauthorized, .unknown: return await createPendingUser(idToken: idToken) - case .network: + case .network, .pendingEmailVerification: return .loginError(error: .network) } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Errors/ServerError.swift b/apple/OmnivoreKit/Sources/Services/DataService/Errors/ServerError.swift index c3781974f..601b2e4cf 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Errors/ServerError.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Errors/ServerError.swift @@ -10,11 +10,15 @@ public enum ServerError: String, Error { case unauthenticated case timeout case unknown + case pendingEmailVerification } extension ServerError { init(serverResponse: ServerResponse) { switch serverResponse.httpUrlResponse?.statusCode { + case 418?: + self = .pendingEmailVerification + return case 401?, 403?: self = .unauthenticated return diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResource.swift b/apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResource.swift index 331bac709..4bf7f11df 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResource.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResource.swift @@ -42,10 +42,9 @@ extension URLSession { do { let (data, response) = try await data(for: resource.urlRequest) let serverResponse = ServerResponse(data: data, response: response) + NetworkRequestLogger.log(request: resource.urlRequest, serverResponse: serverResponse) if let httpResponse = response as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode { - NetworkRequestLogger.log(request: resource.urlRequest, serverResponse: serverResponse) - if let decodedValue = resource.decode(serverResponse) { return decodedValue } @@ -56,7 +55,6 @@ extension URLSession { } } catch { let serverResponse = ServerResponse(error: error) - NetworkRequestLogger.log(request: resource.urlRequest, serverResponse: serverResponse) throw ServerError(serverResponse: serverResponse) } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResources/VerifyAuthProviderToken.swift b/apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResources/VerifyAuthProviderToken.swift index febc2f0cf..0a2e4e4d7 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResources/VerifyAuthProviderToken.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResources/VerifyAuthProviderToken.swift @@ -36,4 +36,54 @@ extension Networker { } } } + + func submitEmailLogin(params: EmailSignInParams) async throws -> EmailAuthPayload { + let encodedParams = (try? JSONEncoder().encode(params)) ?? Data() + + let urlRequest = URLRequest.create( + baseURL: appEnvironment.serverBaseURL, + urlPath: "/api/mobile-auth/email-sign-in", + requestMethod: .post(params: encodedParams) + ) + + let resource = ServerResource( + urlRequest: urlRequest, + decode: EmailAuthPayload.decode + ) + + do { + return try await urlSession.performRequest(resource: resource) + } catch { + if let error = error as? ServerError { + throw LoginError.make(serverError: error) + } else { + throw LoginError.unknown + } + } + } + + func submitEmailSignUp(params: EmailSignUpParams) async throws { + let encodedParams = (try? JSONEncoder().encode(params)) ?? Data() + + let urlRequest = URLRequest.create( + baseURL: appEnvironment.serverBaseURL, + urlPath: "/api/mobile-auth/email-sign-up", + requestMethod: .post(params: encodedParams) + ) + + let resource = ServerResource( + urlRequest: urlRequest, + decode: EmptyResponse.decode + ) + + do { + _ = try await urlSession.performRequest(resource: resource) + } catch { + if let error = error as? ServerError { + throw LoginError.make(serverError: error) + } else { + throw LoginError.unknown + } + } + } } diff --git a/apple/OmnivoreKit/Sources/Views/RegistrationViews/LoginErrorMessageView.swift b/apple/OmnivoreKit/Sources/Views/RegistrationViews/LoginErrorMessageView.swift index 4e3ccf36c..fcb06e2fb 100644 --- a/apple/OmnivoreKit/Sources/Views/RegistrationViews/LoginErrorMessageView.swift +++ b/apple/OmnivoreKit/Sources/Views/RegistrationViews/LoginErrorMessageView.swift @@ -25,6 +25,8 @@ private extension LoginError { return LocalText.networkError case .unknown: return LocalText.genericError + case .pendingEmailVerification: + return "Please check your email for a verification message." } } } diff --git a/packages/api/src/routers/auth/mobile/mobile_auth_router.ts b/packages/api/src/routers/auth/mobile/mobile_auth_router.ts index 6ff44219b..138aae9c8 100644 --- a/packages/api/src/routers/auth/mobile/mobile_auth_router.ts +++ b/packages/api/src/routers/auth/mobile/mobile_auth_router.ts @@ -2,8 +2,14 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import express from 'express' -import { createMobileSignInResponse } from './sign_in' -import { createMobileSignUpResponse } from './sign_up' +import { + createMobileSignInResponse, + createMobileEmailSignInResponse, +} from './sign_in' +import { + createMobileSignUpResponse, + createMobileEmailSignUpResponse, +} from './sign_up' import { createMobileAccountCreationResponse } from './account_creation' export function mobileAuthRouter() { @@ -15,6 +21,23 @@ export function mobileAuthRouter() { res.status(payload.statusCode).json(payload.json) }) + router.post('/email-sign-in', async (req, res) => { + const { email, password } = req.body + const payload = await createMobileEmailSignInResponse(email, password) + res.status(payload.statusCode).json(payload.json) + }) + + router.post('/email-sign-up', async (req, res) => { + const { email, password, username, name } = req.body + const payload = await createMobileEmailSignUpResponse( + email, + password, + username, + name + ) + res.status(payload.statusCode).json(payload.json) + }) + router.post('/sign-up', async (req, res) => { const { token, provider, name } = req.body const payload = await createMobileSignUpResponse(token, provider, name) diff --git a/packages/api/src/routers/auth/mobile/sign_in.ts b/packages/api/src/routers/auth/mobile/sign_in.ts index c3d5112f0..699c38aa0 100644 --- a/packages/api/src/routers/auth/mobile/sign_in.ts +++ b/packages/api/src/routers/auth/mobile/sign_in.ts @@ -8,6 +8,11 @@ import { } from '../auth_types' import { createMobileAuthPayload } from '../jwt_helpers' import UserModel from '../../../datalayer/user' +import { initModels } from '../../../server' +import { sendConfirmationEmail } from '../../../services/send_emails' +import { kx } from '../../../datalayer/knex_config' +import { StatusType } from '../../../datalayer/user/model' +import { comparePassword } from '../../../utils/auth' export async function createMobileSignInResponse( token?: string, @@ -31,6 +36,52 @@ export async function createMobileSignInResponse( } } +export async function createMobileEmailSignInResponse( + email?: string, + password?: string +): Promise { + try { + if (!email || !password) { + throw new Error('Missing username or password') + } + + const models = initModels(kx, false) + const user = await models.user.getWhere({ + email, + }) + + if (!user?.id || !user?.password) { + throw new Error('user not found') + } + + const validPassword = await comparePassword(password, user.password) + if (!validPassword) { + throw new Error('password is invalid') + } + + if (user.status === StatusType.Pending && user.email) { + await sendConfirmationEmail({ + id: user.id, + email: user.email, + name: user.name, + }) + return { + statusCode: 200, + json: { pendingEmailVerification: true }, + } + } + + const mobileAuthPayload = await createMobileAuthPayload(user.id) + + return { + statusCode: 200, + json: mobileAuthPayload, + } + } catch (e) { + return authFailedPayload + } +} + const authFailedPayload = { statusCode: 403, json: { errorCodes: ['AUTH_FAILED'] }, diff --git a/packages/api/src/routers/auth/mobile/sign_up.ts b/packages/api/src/routers/auth/mobile/sign_up.ts index 6cda2bf3f..ef36f481c 100644 --- a/packages/api/src/routers/auth/mobile/sign_up.ts +++ b/packages/api/src/routers/auth/mobile/sign_up.ts @@ -9,6 +9,8 @@ import { } from '../auth_types' import { createPendingUserToken, suggestedUsername } from '../jwt_helpers' import UserModel from '../../../datalayer/user' +import { hashPassword } from '../../../utils/auth' +import { createUser } from '../../../services/create_user' export async function createMobileSignUpResponse( token?: string, @@ -41,6 +43,38 @@ export async function createMobileSignUpResponse( } } +export async function createMobileEmailSignUpResponse( + email?: string, + password?: string, + username?: string, + name?: string +): Promise { + try { + if (!email || !password || !username || !name) { + throw new Error('Missing username, password, name, or username') + } + + const hashedPassword = await hashPassword(password) + + await createUser({ + email, + provider: 'EMAIL', + sourceUserId: email, + name, + username: username.toLowerCase(), + password: hashedPassword, + pendingConfirmation: true, + }) + + return { + statusCode: 200, + json: {}, + } + } catch (e) { + return signUpFailedPayload + } +} + const signUpFailedPayload = { statusCode: 403, json: { errorCodes: ['AUTH_FAILED'] }, diff --git a/packages/web/components/templates/auth/ResetSent.tsx b/packages/web/components/templates/auth/ResetSent.tsx index 6c64dc3af..8c95ce973 100644 --- a/packages/web/components/templates/auth/ResetSent.tsx +++ b/packages/web/components/templates/auth/ResetSent.tsx @@ -11,23 +11,25 @@ export function ResetSent(props: LoginFormProps): JSX.Element { width: '100vw', height: '100vh', bg: '$omnivoreYellow', - overflowY: 'clip' + overflowY: 'clip', }} > - -

Reset email sent

- - If there is an account assosciated with the email specified we sent a - password reset link. Click the link to reset your password. You may need - to check your spam folder. + +

Reset email sent

+ + If there is an account associated with the email specified we sent a + password reset link. Click the link to reset your password. You may + need to check your spam folder. +
-
- + ) }