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.
+
-
-
+
>
)
}