diff --git a/LabWise.xcodeproj/project.pbxproj b/LabWise.xcodeproj/project.pbxproj
index 58631e2..fd48f54 100644
--- a/LabWise.xcodeproj/project.pbxproj
+++ b/LabWise.xcodeproj/project.pbxproj
@@ -429,6 +429,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = LabWise/LabWise.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = YK2DB9NT3S;
@@ -466,6 +467,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = LabWise/LabWise.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = YK2DB9NT3S;
diff --git a/LabWise/LabWise.entitlements b/LabWise/LabWise.entitlements
new file mode 100644
index 0000000..a812db5
--- /dev/null
+++ b/LabWise/LabWise.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.developer.applesignin
+
+ Default
+
+
+
diff --git a/LabWise/LoginView.swift b/LabWise/LoginView.swift
index 6d82ef5..8c6e0a4 100644
--- a/LabWise/LoginView.swift
+++ b/LabWise/LoginView.swift
@@ -9,8 +9,14 @@ struct LoginView: View {
@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 {
@@ -86,8 +92,8 @@ struct LoginView: View {
.background(Color(.brandPrimary))
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 10))
- .disabled(email.isEmpty || password.isEmpty || isLoading || isGoogleLoading)
- .opacity((email.isEmpty || password.isEmpty || isLoading || isGoogleLoading) ? 0.5 : 1)
+ .disabled(email.isEmpty || password.isEmpty || isLoading || isGoogleLoading || isAppleLoading)
+ .opacity((email.isEmpty || password.isEmpty || isLoading || isGoogleLoading || isAppleLoading) ? 0.5 : 1)
HStack {
Rectangle()
@@ -104,7 +110,24 @@ struct LoginView: View {
GoogleSignInButton(isLoading: isGoogleLoading) {
Task { await signInWithGoogle() }
}
- .disabled(isLoading || isGoogleLoading)
+ .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))
@@ -143,6 +166,58 @@ struct LoginView: View {
}
}
+ @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
diff --git a/LabWise/ProfileView.swift b/LabWise/ProfileView.swift
index 4c11cf7..f5bd985 100644
--- a/LabWise/ProfileView.swift
+++ b/LabWise/ProfileView.swift
@@ -12,6 +12,7 @@ final class ProfileViewModel {
var isDeleting = false
// Editable fields
+ var displayName = ""
var piFirstName = ""
var bldgCode = ""
var lab = ""
@@ -36,6 +37,13 @@ final class ProfileViewModel {
isSaving = true
defer { isSaving = false }
do {
+ let trimmedName = displayName.trimmingCharacters(in: .whitespaces)
+ if !trimmedName.isEmpty && trimmedName != AppState.shared.currentUser?.name {
+ let updatedUser = try await authClient.updateName(trimmedName)
+ await MainActor.run {
+ AppState.shared.currentUser = updatedUser
+ }
+ }
let body = UserProfileUpsertBody(
piFirstName: piFirstName,
bldgCode: bldgCode,
@@ -88,6 +96,11 @@ struct ProfileView: View {
Form {
if let user = appState.currentUser {
Section("Account") {
+ if viewModel.isEditing {
+ TextField("Name", text: $viewModel.displayName)
+ } else {
+ LabeledContent("Name", value: user.name ?? "—")
+ }
LabeledContent("Email", value: user.email)
}
}
@@ -149,6 +162,7 @@ struct ProfileView: View {
.disabled(viewModel.isSaving)
} else {
Button("Edit") {
+ viewModel.displayName = appState.currentUser?.name ?? ""
viewModel.isEditing = true
}
}
@@ -157,6 +171,7 @@ struct ProfileView: View {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") {
viewModel.isEditing = false
+ viewModel.displayName = appState.currentUser?.name ?? ""
if let p = viewModel.profile {
viewModel.piFirstName = p.piFirstName
viewModel.bldgCode = p.bldgCode
diff --git a/LabWiseKit/Sources/LabWiseKit/Auth/AuthClient.swift b/LabWiseKit/Sources/LabWiseKit/Auth/AuthClient.swift
index d39b32f..3d4b318 100644
--- a/LabWiseKit/Sources/LabWiseKit/Auth/AuthClient.swift
+++ b/LabWiseKit/Sources/LabWiseKit/Auth/AuthClient.swift
@@ -1,5 +1,6 @@
import Foundation
import AuthenticationServices
+import CryptoKit
public struct SignInBody: Encodable, Sendable {
public let email: String
@@ -11,6 +12,35 @@ public struct SignInBody: Encodable, Sendable {
}
}
+/// Mirrors Better Auth's POST /sign-in/social body for the id-token (native) flow.
+public struct AppleSocialSignInBody: Encodable, Sendable {
+ public let provider: String
+ public let idToken: AppleIdTokenPayload
+
+ public struct AppleIdTokenPayload: Encodable, Sendable {
+ public let token: String
+ public let nonce: String?
+ public let user: AppleUser?
+ }
+
+ public struct AppleUser: Encodable, Sendable {
+ public let name: AppleName?
+ public let email: String?
+ }
+
+ public struct AppleName: Encodable, Sendable {
+ public let firstName: String?
+ public let lastName: String?
+ }
+}
+
+/// Better Auth's response from /sign-in/social when an idToken is supplied.
+private struct SocialIdTokenResponse: Decodable, Sendable {
+ let token: String?
+ let user: AuthUser?
+ let redirect: Bool?
+}
+
/// Server returns { token, user } directly (or { error: { message } } on failure)
public struct SignInResponse: Decodable, Sendable {
public let token: String?
@@ -161,6 +191,108 @@ public final class AuthClient: Sendable {
return try await fetchCurrentUser()
}
+ // MARK: - Sign in with Apple
+
+ /// Generates a cryptographically random string suitable for use as the
+ /// raw nonce in a Sign in with Apple request. Pass the SHA-256 hash of
+ /// this value to ASAuthorizationAppleIDRequest.nonce, and pass the same
+ /// hashed value to `signInWithApple(idToken:hashedNonce:)`.
+ public static func generateAppleNonceRaw(length: Int = 32) -> String {
+ let charset: [Character] = Array("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-._")
+ var result = ""
+ var remaining = length
+ while remaining > 0 {
+ var randoms = [UInt8](repeating: 0, count: 16)
+ let status = SecRandomCopyBytes(kSecRandomDefault, randoms.count, &randoms)
+ precondition(status == errSecSuccess, "Unable to generate secure random bytes for Apple nonce")
+ for byte in randoms where remaining > 0 {
+ if byte < charset.count {
+ result.append(charset[Int(byte)])
+ remaining -= 1
+ }
+ }
+ }
+ return result
+ }
+
+ /// SHA-256 hashes the raw nonce as a lowercase hex string. This is the
+ /// value that should be passed to ASAuthorizationAppleIDRequest.nonce
+ /// AND forwarded to Better Auth, since the JWT Apple returns echoes back
+ /// the hashed nonce.
+ public static func sha256Hex(_ input: String) -> String {
+ let digest = SHA256.hash(data: Data(input.utf8))
+ return digest.map { String(format: "%02x", $0) }.joined()
+ }
+
+ /// Completes Sign in with Apple using a native identity token.
+ ///
+ /// Flow:
+ /// 1. The caller obtains an `ASAuthorizationAppleIDCredential` via
+ /// `ASAuthorizationController` (typically through SwiftUI's
+ /// `SignInWithAppleButton`).
+ /// 2. The caller passes the credential's `identityToken` (decoded as a
+ /// UTF-8 string), the SHA-256-hashed nonce that was used in the
+ /// request, and the user's name (only available the very first time
+ /// Apple consents the user) to this method.
+ /// 3. We POST `{ provider: "apple", idToken: { token, nonce, user } }`
+ /// to Better Auth's `/api/auth/sign-in/social` endpoint. Better Auth
+ /// verifies the JWT against Apple's public keys (audience = our iOS
+ /// bundle identifier) and returns the session token.
+ /// 4. We inject the returned token into HTTPCookieStorage so subsequent
+ /// URLSession requests are authenticated, then return the user.
+ public func signInWithApple(
+ idToken: String,
+ hashedNonce: String?,
+ firstName: String? = nil,
+ lastName: String? = nil,
+ email: String? = nil
+ ) async throws -> AuthUser {
+ let userPayload: AppleSocialSignInBody.AppleUser?
+ if firstName != nil || lastName != nil || email != nil {
+ userPayload = .init(
+ name: (firstName != nil || lastName != nil)
+ ? .init(firstName: firstName, lastName: lastName)
+ : nil,
+ email: email
+ )
+ } else {
+ userPayload = nil
+ }
+
+ let body = AppleSocialSignInBody(
+ provider: "apple",
+ idToken: .init(token: idToken, nonce: hashedNonce, user: userPayload)
+ )
+ let encoded = try JSONEncoder().encode(body)
+
+ var req = URLRequest(url: URL(string: "\(baseURL)/api/auth/sign-in/social")!)
+ req.httpMethod = "POST"
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.setValue(baseURL, forHTTPHeaderField: "Origin")
+ req.httpBody = encoded
+
+ let session = sharedURLSession()
+ let (data, response) = try await session.data(for: req)
+
+ guard let http = response as? HTTPURLResponse else {
+ throw APIError.networkError(URLError(.badServerResponse))
+ }
+ guard http.statusCode == 200 else {
+ throw APIError.httpError(statusCode: http.statusCode, data: data)
+ }
+
+ let decoded = try JSONDecoder.api.decode(SocialIdTokenResponse.self, from: data)
+ guard let user = decoded.user else {
+ throw APIError.decodingError(DecodingError.dataCorrupted(
+ .init(codingPath: [], debugDescription: "Missing user in Apple sign-in response")
+ ))
+ }
+ if let token = decoded.token {
+ injectSessionCookie(token: token)
+ }
+ return user
+ }
+
// MARK: - Cookie injection
private func injectSessionCookie(token: String) {
@@ -200,6 +332,19 @@ public final class AuthClient: Sendable {
return user
}
+ // MARK: - Update Name
+
+ private struct UpdateNameBody: Encodable, Sendable {
+ let name: String
+ }
+
+ /// Update the authenticated user's display name.
+ public func updateName(_ name: String) async throws -> AuthUser {
+ let body = UpdateNameBody(name: name)
+ _ = try await api.postRaw("/api/auth/update-user", body: body)
+ return try await fetchCurrentUser()
+ }
+
// MARK: - Delete Account
/// Permanently delete the user's account and all associated data.