Merge pull request #1024 from omnivore-app/feature/email-login-ios

Email Login (iOS)
This commit is contained in:
Satindar Dhillon 2022-08-03 20:51:13 -07:00 committed by GitHub
commit 5da9939267
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 792 additions and 67 deletions

View file

@ -8,6 +8,16 @@
<array>
<string>Default</string>
</array>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:omnivore.app</string>
<string>applinks:dev.omnivore.app</string>
<string>applinks:demo.omnivore.app</string>
<string>webcredentials:omnivore.app</string>
<string>webcredentials:dev.omnivore.app</string>
</array>
<key>com.apple.developer.authentication-services.autofill-credential-provider</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.omnivoreapp</string>
@ -16,11 +26,5 @@
<array>
<string>$(AppIdentifierPrefix)app.omnivore.shared</string>
</array>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:omnivore.app</string>
<string>applinks:dev.omnivore.app</string>
<string>applinks:demo.omnivore.app</string>
</array>
</dict>
</plist>

View file

@ -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 = "<group>"; };
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 = "<group>"; };
0411792B26A22860004AE24F /* MacUnitTests.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = MacUnitTests.plist; sourceTree = "<group>"; };
@ -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 = "<group>";
@ -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 = "";

View file

@ -224,7 +224,7 @@ struct CreateProfileView: View {
}
}
private extension PotentialUsernameStatus {
extension PotentialUsernameStatus {
var message: String? {
switch self {
case .tooShort:

View file

@ -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<AnyCancellable>()
}
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) }
)
}
}
}
}
}
}

View file

@ -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")
}
}

View file

@ -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)
}
}
}

View file

@ -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

View file

@ -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)
}

View file

@ -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")!
}
}
}

View file

@ -4,4 +4,5 @@ public enum LoginError: Error {
case unauthorized
case network
case unknown
case pendingEmailVerification
}

View file

@ -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)
}
}
}

View file

@ -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
}
}
}

View file

@ -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)
}
}

View file

@ -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

View file

@ -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)
}
}

View file

@ -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<EmailAuthPayload>(
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<EmptyResponse>(
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
}
}
}
}

View file

@ -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."
}
}
}

View file

@ -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)

View file

@ -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<JsonResponsePayload> {
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'] },

View file

@ -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<JsonResponsePayload> {
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'] },

View file

@ -11,23 +11,25 @@ export function ResetSent(props: LoginFormProps): JSX.Element {
width: '100vw',
height: '100vh',
bg: '$omnivoreYellow',
overflowY: 'clip'
overflowY: 'clip',
}}
>
<Box css={{
width: '100%',
margin: '40px',
color: '$omnivoreGray',
'@xl': { margin: '138px' },
}}>
<h1>Reset email sent</h1>
<Box>
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.
<Box
css={{
width: '100%',
margin: '40px',
color: '$omnivoreGray',
'@xl': { margin: '138px' },
}}
>
<h1>Reset email sent</h1>
<Box>
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.
</Box>
</Box>
</Box>
</HStack>
</HStack>
</>
)
}