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 @State private var isAppleLoading = false @State private var errorMessage: String? // 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? 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)) } // 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) ) 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) ) } 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)) .foregroundStyle(.white) .clipShape(RoundedRectangle(cornerRadius: 10)) .disabled(email.isEmpty || password.isEmpty || isLoading || isGoogleLoading || isAppleLoading) .opacity((email.isEmpty || password.isEmpty || isLoading || isGoogleLoading || isAppleLoading) ? 0.5 : 1) HStack { Rectangle() .fill(Color(.brandPrimary).opacity(0.15)) .frame(height: 1) Text("or") .font(.footnote) .foregroundStyle(Color(.brandMutedForeground)) Rectangle() .fill(Color(.brandPrimary).opacity(0.15)) .frame(height: 1) } GoogleSignInButton(isLoading: isGoogleLoading) { Task { await signInWithGoogle() } } .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) } .padding(24) .background(Color(UIColor.secondarySystemGroupedBackground)) .clipShape(RoundedRectangle(cornerRadius: 16)) .shadow(color: Color(.brandPrimary).opacity(0.06), radius: 12, x: 0, y: 4) .padding(.horizontal, 24) Spacer() Link("Privacy Policy", destination: URL(string: "https://labwise.wahwa.com/privacy")!) .font(.footnote) .foregroundStyle(Color(.brandMutedForeground)) } } .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." } } } @MainActor private func handleAppleCompletion(_ result: Result) 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." } } } @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) 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) ) } .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() } } #Preview { LoginView() .environment(AppState.shared) }