Files
LabWiseiOS/LabWise/LoginView.swift

290 lines
12 KiB
Swift
Raw Normal View History

2026-03-20 02:05:53 -05:00
import SwiftUI
import AuthenticationServices
import LabWiseKit
struct LoginView: View {
@Environment(AppState.self) private var appState
@State private var email = ""
@State private var password = ""
@State private var isLoading = false
@State private var isGoogleLoading = false
2026-04-10 01:44:02 -05:00
@State private var isAppleLoading = false
2026-03-20 02:05:53 -05:00
@State private var errorMessage: String?
2026-04-10 01:44:02 -05:00
// Apple sign-in: we generate a fresh raw nonce each time the button is
// tapped, hash it, hand the hash to Apple, then forward the same hash
// to Better Auth so it can verify the JWT echoed back in `nonce`.
@State private var currentAppleRawNonce: String?
2026-03-20 02:05:53 -05:00
private let authClient = AuthClient()
var body: some View {
NavigationStack {
ZStack {
Color(UIColor.systemGroupedBackground)
.ignoresSafeArea()
VStack(spacing: 32) {
Spacer()
// Logo
VStack(spacing: 16) {
Image("Logo")
.resizable()
.scaledToFit()
.frame(height: 52)
Text("Chemical Inventory Management")
.font(.subheadline)
.foregroundStyle(Color(.brandMutedForeground))
2026-03-20 02:05:53 -05:00
}
// Card
VStack(spacing: 20) {
VStack(spacing: 12) {
TextField("Email", text: $email)
.keyboardType(.emailAddress)
.textContentType(.emailAddress)
.autocapitalization(.none)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Color(UIColor.secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(Color(.brandPrimary).opacity(0.2), lineWidth: 1)
2026-03-20 02:05:53 -05:00
)
SecureField("Password", text: $password)
.textContentType(.password)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Color(UIColor.secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(Color(.brandPrimary).opacity(0.2), lineWidth: 1)
2026-03-20 02:05:53 -05:00
)
}
if let errorMessage {
Text(errorMessage)
.foregroundStyle(.red)
.font(.footnote)
.multilineTextAlignment(.center)
}
Button {
Task { await signIn() }
} label: {
Group {
if isLoading {
ProgressView()
.tint(.white)
} else {
Text("Sign In")
.fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.frame(height: 44)
}
.background(Color(.brandPrimary))
2026-03-20 02:05:53 -05:00
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 10))
2026-04-10 01:44:02 -05:00
.disabled(email.isEmpty || password.isEmpty || isLoading || isGoogleLoading || isAppleLoading)
.opacity((email.isEmpty || password.isEmpty || isLoading || isGoogleLoading || isAppleLoading) ? 0.5 : 1)
2026-03-20 02:05:53 -05:00
HStack {
Rectangle()
.fill(Color(.brandPrimary).opacity(0.15))
2026-03-20 02:05:53 -05:00
.frame(height: 1)
Text("or")
.font(.footnote)
.foregroundStyle(Color(.brandMutedForeground))
2026-03-20 02:05:53 -05:00
Rectangle()
.fill(Color(.brandPrimary).opacity(0.15))
2026-03-20 02:05:53 -05:00
.frame(height: 1)
}
GoogleSignInButton(isLoading: isGoogleLoading) {
Task { await signInWithGoogle() }
}
2026-04-10 01:44:02 -05:00
.disabled(isLoading || isGoogleLoading || isAppleLoading)
SignInWithAppleButton(
onRequest: { request in
let raw = AuthClient.generateAppleNonceRaw()
currentAppleRawNonce = raw
request.requestedScopes = [.fullName, .email]
request.nonce = AuthClient.sha256Hex(raw)
},
onCompletion: { result in
Task { await handleAppleCompletion(result) }
}
)
.signInWithAppleButtonStyle(.black)
.frame(height: 44)
.clipShape(RoundedRectangle(cornerRadius: 10))
.disabled(isLoading || isGoogleLoading || isAppleLoading)
.opacity(isAppleLoading ? 0.5 : 1)
2026-03-20 02:05:53 -05:00
}
.padding(24)
.background(Color(UIColor.secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: Color(.brandPrimary).opacity(0.06), radius: 12, x: 0, y: 4)
2026-03-20 02:05:53 -05:00
.padding(.horizontal, 24)
Spacer()
2026-04-09 14:28:40 -05:00
Link("Privacy Policy", destination: URL(string: "https://labwise.wahwa.com/privacy")!)
.font(.footnote)
.foregroundStyle(Color(.brandMutedForeground))
2026-03-20 02:05:53 -05:00
}
}
.navigationBarHidden(true)
}
}
// MARK: - Actions
private func signIn() async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
let user = try await authClient.signIn(email: email, password: password)
await MainActor.run { appState.signedIn(user: user) }
} catch APIError.httpError(let code, _) where code == 401 {
await MainActor.run { errorMessage = "Invalid email or password." }
} catch APIError.httpError(_, let data) {
let msg = String(data: data, encoding: .utf8) ?? "Sign in failed."
await MainActor.run { errorMessage = msg }
} catch {
await MainActor.run { errorMessage = "Sign in failed. Please check your connection." }
}
}
2026-04-10 01:44:02 -05:00
@MainActor
private func handleAppleCompletion(_ result: Result<ASAuthorization, Error>) async {
isAppleLoading = true
errorMessage = nil
defer {
isAppleLoading = false
currentAppleRawNonce = nil
}
switch result {
case .failure(let error):
if let asError = error as? ASAuthorizationError, asError.code == .canceled {
return // user cancelled silent
}
errorMessage = "Apple sign-in failed. Please try again."
case .success(let authorization):
guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential,
let identityTokenData = credential.identityToken,
let identityToken = String(data: identityTokenData, encoding: .utf8) else {
errorMessage = "Apple sign-in failed: missing identity token."
return
}
// The hashed nonce we sent to Apple Better Auth needs the same
// value to compare against the JWT's `nonce` claim.
let hashedNonce = currentAppleRawNonce.map(AuthClient.sha256Hex)
// Apple only delivers fullName / email the very first time the
// user authorises this app. Forward whatever we got Better
// Auth's getUserInfo will use these to populate the user record.
let firstName = credential.fullName?.givenName
let lastName = credential.fullName?.familyName
let email = credential.email
do {
let user = try await authClient.signInWithApple(
idToken: identityToken,
hashedNonce: hashedNonce,
firstName: firstName,
lastName: lastName,
email: email
)
appState.signedIn(user: user)
} catch APIError.httpError(_, let data) {
let msg = String(data: data, encoding: .utf8) ?? "Apple sign-in failed."
errorMessage = msg
} catch {
errorMessage = "Apple sign-in failed. Please check your connection."
}
}
}
2026-03-20 02:05:53 -05:00
@MainActor
private func signInWithGoogle() async {
guard let window = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.flatMap({ $0.windows })
.first(where: { $0.isKeyWindow }) else { return }
isGoogleLoading = true
errorMessage = nil
defer { isGoogleLoading = false }
do {
let user = try await authClient.signInWithGoogle(presentingWindow: window)
appState.signedIn(user: user)
} catch ASWebAuthenticationSessionError.canceledLogin {
// User cancelled no error shown
} catch {
errorMessage = "Google sign-in failed. Please try again."
}
}
}
// MARK: - Google button
struct GoogleSignInButton: View {
let isLoading: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 10) {
if isLoading {
ProgressView()
.frame(maxWidth: .infinity)
} else {
GoogleGLogo()
.frame(width: 18, height: 18)
2026-03-20 02:05:53 -05:00
Text("Continue with Google")
.fontWeight(.medium)
.foregroundStyle(Color(UIColor.label))
}
}
.frame(maxWidth: .infinity)
.frame(height: 44)
.background(Color(UIColor.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(Color(.brandPrimary).opacity(0.25), lineWidth: 1)
2026-03-20 02:05:53 -05:00
)
}
.buttonStyle(.plain)
}
}
/// The four-colour Google "G" logo, matching the web app implementation.
struct GoogleGLogo: View {
var body: some View {
Image("GoogleLogo")
.resizable()
.renderingMode(.original)
.scaledToFit()
}
}
2026-03-20 02:05:53 -05:00
#Preview {
LoginView()
.environment(AppState.shared)
}