Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 151 additions & 65 deletions App/FeedbackPortalApp/SignInSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,75 +5,46 @@ struct SignInSheet: View {
@EnvironmentObject private var auth: AuthStore
@Environment(\.dismiss) private var dismiss

private enum Method: String, CaseIterable {
case code = "Email Code"
case password = "Password"
}

@State private var method: Method = .code

// Shared
@State private var email = ""
@State private var code = ""
@State private var codeSent = false
@State private var isSending = false

// OTP
@State private var otp = ""
@State private var codeSent = false

// Password
@State private var password = ""
@State private var name = ""
@State private var isCreatingAccount = false

var body: some View {
NavigationStack {
Form {
Section {
TextField("Email", text: $email)
.keyboardType(.emailAddress)
.textContentType(.emailAddress)
.autocapitalization(.none)
.autocorrectionDisabled()
.disabled(codeSent)
Picker("Method", selection: $method) {
ForEach(Method.allCases, id: \.self) { Text($0.rawValue).tag($0) }
}
.pickerStyle(.segmented)
}

if codeSent {
Section {
TextField("6-digit code", text: $code)
.keyboardType(.numberPad)
.textContentType(.oneTimeCode)
}
switch method {
case .code: otpSection
case .password: passwordSection
}

if let error = auth.errorMessage {
Section {
Text(error)
.foregroundStyle(.red)
.font(.subheadline)
}
Section { Text(error).foregroundStyle(.red).font(.subheadline) }
}

Section {
if !codeSent {
Button {
Task { await sendCode() }
} label: {
if isSending {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Text("Send Code")
.frame(maxWidth: .infinity)
}
}
.disabled(email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending)
} else {
Button {
Task { await verify() }
} label: {
if isSending {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Text("Verify")
.frame(maxWidth: .infinity)
}
}
.disabled(code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending)

Button("Resend Code") {
code = ""
codeSent = false
}
.buttonStyle(.borderless)
.frame(maxWidth: .infinity)
.foregroundStyle(.secondary)
}
if let info = auth.infoMessage {
Section { Text(info).foregroundStyle(.secondary).font(.subheadline) }
}
}
.navigationTitle("Sign In")
Expand All @@ -85,26 +56,141 @@ struct SignInSheet: View {
}
}
.onChange(of: auth.isSignedIn) { _, isSignedIn in
if isSignedIn {
dismiss()
if isSignedIn { dismiss() }
}
.onChange(of: method) { _, _ in
isSending = false
codeSent = false
otp = ""
isCreatingAccount = false
auth.clearMessages()
}
}

// MARK: - OTP

@ViewBuilder private var otpSection: some View {
Section {
TextField("Email", text: $email)
.keyboardType(.emailAddress)
.textContentType(.emailAddress)
.autocapitalization(.none)
.autocorrectionDisabled()
.disabled(codeSent)
}

if codeSent {
Section {
TextField("6-digit code", text: $otp)
.keyboardType(.numberPad)
.textContentType(.oneTimeCode)
}
}

Section {
if !codeSent {
primaryButton("Send Code", disabled: emailEmpty || isSending) { await sendCode() }
} else {
primaryButton("Verify", disabled: otp.trimmed.isEmpty) { await verify() }
Button("Resend Code") { otp = ""; codeSent = false }
.buttonStyle(.borderless)
.frame(maxWidth: .infinity)
.foregroundStyle(.secondary)
.disabled(isSending)
}
}
}

// MARK: - Password

@ViewBuilder private var passwordSection: some View {
Section {
if isCreatingAccount {
TextField("Name", text: $name)
.textContentType(.name)
}
TextField("Email", text: $email)
.keyboardType(.emailAddress)
.textContentType(.emailAddress)
.autocapitalization(.none)
.autocorrectionDisabled()
SecureField("Password (min 8 characters)", text: $password)
.textContentType(isCreatingAccount ? .newPassword : .password)
}

Section {
if isCreatingAccount {
primaryButton("Create Account", disabled: !canSubmitPassword || name.trimmed.isEmpty) { await signUp() }
} else {
primaryButton("Sign In", disabled: !canSubmitPassword) { await signInWithPassword() }
}

Button(isCreatingAccount ? "Have an account? Sign in" : "Create an account") {
isCreatingAccount.toggle()
}
.buttonStyle(.borderless)
.frame(maxWidth: .infinity)

if !isCreatingAccount {
Button("Forgot password?") { Task { await requestReset() } }
.buttonStyle(.borderless)
.frame(maxWidth: .infinity)
.foregroundStyle(.secondary)
.disabled(emailEmpty || isSending)
}
}
}

// MARK: - Helpers

private var emailEmpty: Bool { email.trimmed.isEmpty }
private var canSubmitPassword: Bool { !emailEmpty && password.count >= 8 && !isSending }

@ViewBuilder
private func primaryButton(_ title: String, disabled: Bool, action: @escaping () async -> Void) -> some View {
Button {
Task { await action() }
} label: {
if isSending {
ProgressView().frame(maxWidth: .infinity)
} else {
Text(title).frame(maxWidth: .infinity)
}
}
.disabled(disabled)
}

private func sendCode() async {
isSending = true
do {
try await auth.requestCode(email: email)
codeSent = true
} catch {
// errorMessage surfaced through auth.errorMessage if set by AuthStore
}
do { try await auth.requestCode(email: email); codeSent = true } catch {}
isSending = false
}

private func verify() async {
isSending = true
await auth.verify(email: email, code: code)
await auth.verify(email: email, code: otp)
isSending = false
}

private func signInWithPassword() async {
isSending = true
await auth.signInWithPassword(email: email, password: password)
isSending = false
}

private func signUp() async {
isSending = true
await auth.signUp(name: name, email: email, password: password)
isSending = false
}

private func requestReset() async {
isSending = true
await auth.requestPasswordReset(email: email)
isSending = false
}
}

private extension String {
var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) }
}
1 change: 1 addition & 0 deletions Sources/FeedbackKit/API/APIError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ public enum APIError: Error, Equatable, Sendable {
case server(status: Int, code: String?)
case transport(String)
case decoding(String)
case auth(code: String?, message: String?)
}
59 changes: 57 additions & 2 deletions Sources/FeedbackKit/Auth/AuthStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@ import Foundation
public protocol AuthService: Sendable {
func sendOTP(email: String) async throws
func verifyOTP(email: String, code: String) async throws -> String // returns session token
func signInWithPassword(email: String, password: String) async throws -> String // session token
func signUp(name: String, email: String, password: String) async throws -> String // session token
func requestPasswordReset(email: String) async throws
}

@MainActor
public final class AuthStore: ObservableObject {
@Published public private(set) var isSignedIn: Bool
@Published public private(set) var errorMessage: String?
@Published public private(set) var infoMessage: String?

private let service: AuthService
private let tokenStore: TokenStore
Expand All @@ -22,7 +26,7 @@ public final class AuthStore: ObservableObject {
public var token: String? { tokenStore.token }

public func requestCode(email: String) async throws {
errorMessage = nil
errorMessage = nil; infoMessage = nil
do {
try await service.sendOTP(email: email)
} catch {
Expand All @@ -32,7 +36,7 @@ public final class AuthStore: ObservableObject {
}

public func verify(email: String, code: String) async {
errorMessage = nil
errorMessage = nil; infoMessage = nil
do {
let token = try await service.verifyOTP(email: email, code: code)
tokenStore.token = token
Expand All @@ -43,6 +47,57 @@ public final class AuthStore: ObservableObject {
}
}

public func signInWithPassword(email: String, password: String) async {
errorMessage = nil; infoMessage = nil
do {
let token = try await service.signInWithPassword(email: email, password: password)
tokenStore.token = token
isSignedIn = true
} catch {
errorMessage = Self.authMessage(for: error)
isSignedIn = false
}
}

public func signUp(name: String, email: String, password: String) async {
errorMessage = nil; infoMessage = nil
do {
let token = try await service.signUp(name: name, email: email, password: password)
tokenStore.token = token
isSignedIn = true
} catch {
errorMessage = Self.authMessage(for: error)
isSignedIn = false
}
}

public func requestPasswordReset(email: String) async {
errorMessage = nil; infoMessage = nil
do {
try await service.requestPasswordReset(email: email)
infoMessage = "Check your email for a reset link."
} catch {
errorMessage = Self.authMessage(for: error)
}
}

static func authMessage(for error: Error) -> String {
if case let APIError.auth(code, message) = error {
switch code {
case "INVALID_EMAIL_OR_PASSWORD": return "Wrong email or password."
case "USER_ALREADY_EXISTS": return "An account with this email already exists."
case "VALIDATION_ERROR": return message ?? "Password must be at least 8 characters."
default: break
}
}
return "Something went wrong. Please try again."
}

public func clearMessages() {
errorMessage = nil
infoMessage = nil
}

public func signOut() {
tokenStore.token = nil
isSignedIn = false
Expand Down
Loading
Loading