Эх сурвалжийг харах

Polish Google login UX with profile details.

Add Google profile fetching and display (name/email/avatar), improve auth button states for login/logout, and switch token storage to app defaults to avoid repeated keychain password prompts.

Made-with: Cursor
huzaifahayat12 3 сар өмнө
parent
commit
d7f04359e6

+ 23 - 1
meetings_app/Auth/GoogleOAuthService.swift

@@ -11,6 +11,12 @@ struct GoogleOAuthTokens: Codable, Equatable {
     var tokenType: String?
 }
 
+struct GoogleUserProfile: Codable, Equatable {
+    var name: String?
+    var email: String?
+    var picture: String?
+}
+
 enum GoogleOAuthError: Error {
     case missingClientId
     case missingClientSecret
@@ -32,8 +38,11 @@ final class GoogleOAuthService: NSObject {
     private let bundledClientId = "824412072260-m9g5g6mlemnb0o079rtuqnh0e1unmelc.apps.googleusercontent.com"
     private let bundledClientSecret = "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"
 
-    // Calendar readonly is needed for the Schedule list. Meet scopes can be added later when needed.
+    // Calendar is needed for schedule. Profile/email make login feel complete in-app.
     private let scopes = [
+        "openid",
+        "email",
+        "profile",
         "https://www.googleapis.com/auth/calendar.readonly"
     ]
 
@@ -68,6 +77,19 @@ final class GoogleOAuthService: NSObject {
         try? tokenStore.readTokens()
     }
 
+    func fetchUserProfile(accessToken: String) async throws -> GoogleUserProfile {
+        var request = URLRequest(url: URL(string: "https://openidconnect.googleapis.com/v1/userinfo")!)
+        request.httpMethod = "GET"
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+
+        let (data, response) = try await URLSession.shared.data(for: request)
+        guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
+            let details = String(data: data, encoding: .utf8) ?? "HTTP \((response as? HTTPURLResponse)?.statusCode ?? -1)"
+            throw GoogleOAuthError.tokenExchangeFailed(details)
+        }
+        return try JSONDecoder().decode(GoogleUserProfile.self, from: data)
+    }
+
     func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
         if var tokens = try tokenStore.readTokens() {
             if tokens.expiresAt.timeIntervalSinceNow > 60 {

+ 11 - 53
meetings_app/Auth/KeychainTokenStore.swift

@@ -1,72 +1,30 @@
 import Foundation
-import Security
-
-enum KeychainTokenStoreError: Error {
-    case unexpectedStatus(OSStatus)
-    case missingData
-}
 
+/// Keeps the existing API surface while storing OAuth tokens in UserDefaults.
+/// This avoids macOS keychain unlock prompts during development/test runs.
 final class KeychainTokenStore {
-    private let service: String
-    private let account: String
+    private let defaultsKey: String
+    private let defaults: UserDefaults
 
     init(service: String = Bundle.main.bundleIdentifier ?? "meetings_app",
-         account: String = "googleOAuthTokens") {
-        self.service = service
-        self.account = account
+         account: String = "googleOAuthTokens",
+         defaults: UserDefaults = .standard) {
+        self.defaultsKey = "\(service).\(account)"
+        self.defaults = defaults
     }
 
     func readTokens() throws -> GoogleOAuthTokens? {
-        let query: [String: Any] = [
-            kSecClass as String: kSecClassGenericPassword,
-            kSecAttrService as String: service,
-            kSecAttrAccount as String: account,
-            kSecMatchLimit as String: kSecMatchLimitOne,
-            kSecReturnData as String: true
-        ]
-
-        // Access group / accessibility can be set here if needed later.
-        var item: CFTypeRef?
-        let status = SecItemCopyMatching(query as CFDictionary, &item)
-        if status == errSecItemNotFound { return nil }
-        guard status == errSecSuccess else { throw KeychainTokenStoreError.unexpectedStatus(status) }
-        guard let data = item as? Data else { throw KeychainTokenStoreError.missingData }
+        guard let data = defaults.data(forKey: defaultsKey) else { return nil }
         return try JSONDecoder().decode(GoogleOAuthTokens.self, from: data)
     }
 
     func writeTokens(_ tokens: GoogleOAuthTokens) throws {
         let data = try JSONEncoder().encode(tokens)
-
-        // Upsert.
-        let query: [String: Any] = [
-            kSecClass as String: kSecClassGenericPassword,
-            kSecAttrService as String: service,
-            kSecAttrAccount as String: account
-        ]
-
-        let attributes: [String: Any] = [
-            kSecValueData as String: data
-        ]
-
-        let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
-        if status == errSecSuccess { return }
-        if status != errSecItemNotFound { throw KeychainTokenStoreError.unexpectedStatus(status) }
-
-        var add = query
-        add[kSecValueData as String] = data
-        let addStatus = SecItemAdd(add as CFDictionary, nil)
-        guard addStatus == errSecSuccess else { throw KeychainTokenStoreError.unexpectedStatus(addStatus) }
+        defaults.set(data, forKey: defaultsKey)
     }
 
     func deleteTokens() throws {
-        let query: [String: Any] = [
-            kSecClass as String: kSecClassGenericPassword,
-            kSecAttrService as String: service,
-            kSecAttrAccount as String: account
-        ]
-        let status = SecItemDelete(query as CFDictionary)
-        if status == errSecSuccess || status == errSecItemNotFound { return }
-        throw KeychainTokenStoreError.unexpectedStatus(status)
+        defaults.removeObject(forKey: defaultsKey)
     }
 }
 

+ 160 - 14
meetings_app/ViewController.swift

@@ -38,6 +38,12 @@ private enum PremiumPlan: Int {
 }
 
 final class ViewController: NSViewController {
+    private struct GoogleProfileDisplay {
+        let name: String
+        let email: String
+        let pictureURL: URL?
+    }
+
     private var palette = Palette(isDarkMode: true)
     private let typography = Typography()
     private let launchContentSize = NSSize(width: 920, height: 690)
@@ -78,6 +84,12 @@ final class ViewController: NSViewController {
     private weak var scheduleScrollLeftButton: NSView?
     private weak var scheduleScrollRightButton: NSView?
     private weak var scheduleFilterDropdown: NSPopUpButton?
+    private weak var scheduleGoogleAuthButton: NSButton?
+    private weak var scheduleProfileContainer: NSView?
+    private weak var scheduleProfileNameLabel: NSTextField?
+    private weak var scheduleProfileEmailLabel: NSTextField?
+    private weak var scheduleProfileImageView: NSImageView?
+    private var scheduleProfileImageTask: Task<Void, Never>?
 
     /// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`).
     private let inAppBrowserDefaultPolicy: InAppBrowserURLPolicy = .allowAll
@@ -1805,9 +1817,15 @@ private extension ViewController {
 
         row.addArrangedSubview(makeScheduleRefreshButton())
 
-        let connectButton = makeSchedulePillButton(title: googleOAuth.loadTokens() == nil ? "Connect" : "Connected")
+        let profileBadge = makeScheduleProfileBadge()
+        scheduleProfileContainer = profileBadge
+        row.addArrangedSubview(profileBadge)
+
+        let connectButton = makeSchedulePillButton(title: googleOAuth.loadTokens() == nil ? "Login with Google" : "Logout")
         connectButton.target = self
         connectButton.action = #selector(scheduleConnectButtonPressed(_:))
+        scheduleGoogleAuthButton = connectButton
+        updateGoogleAuthButtonTitle()
         row.addArrangedSubview(connectButton)
 
         row.addArrangedSubview(makeScheduleFilterDropdown())
@@ -1859,10 +1877,58 @@ private extension ViewController {
         button.contentTintColor = palette.textSecondary
         button.setButtonType(.momentaryChange)
         button.heightAnchor.constraint(equalToConstant: 34).isActive = true
-        button.widthAnchor.constraint(greaterThanOrEqualToConstant: 104).isActive = true
+        button.widthAnchor.constraint(greaterThanOrEqualToConstant: 132).isActive = true
         return button
     }
 
+    private func makeScheduleProfileBadge() -> NSView {
+        let chip = roundedContainer(cornerRadius: 16, color: palette.inputBackground)
+        chip.translatesAutoresizingMaskIntoConstraints = false
+        styleSurface(chip, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
+        chip.heightAnchor.constraint(equalToConstant: 34).isActive = true
+        chip.widthAnchor.constraint(equalToConstant: 230).isActive = true
+        chip.isHidden = true
+
+        let avatar = NSImageView()
+        avatar.translatesAutoresizingMaskIntoConstraints = false
+        avatar.wantsLayer = true
+        avatar.layer?.cornerRadius = 11
+        avatar.layer?.masksToBounds = true
+        avatar.imageScaling = .scaleAxesIndependently
+        avatar.widthAnchor.constraint(equalToConstant: 22).isActive = true
+        avatar.heightAnchor.constraint(equalToConstant: 22).isActive = true
+        avatar.image = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Profile")
+        avatar.contentTintColor = palette.textSecondary
+
+        let name = textLabel("Google User", font: NSFont.systemFont(ofSize: 11, weight: .semibold), color: palette.textPrimary)
+        let email = textLabel("Signed in", font: NSFont.systemFont(ofSize: 10, weight: .regular), color: palette.textMuted)
+        name.lineBreakMode = .byTruncatingTail
+        email.lineBreakMode = .byTruncatingTail
+        name.maximumNumberOfLines = 1
+        email.maximumNumberOfLines = 1
+
+        let textStack = NSStackView(views: [name, email])
+        textStack.translatesAutoresizingMaskIntoConstraints = false
+        textStack.orientation = .vertical
+        textStack.spacing = 0
+        textStack.alignment = .leading
+
+        chip.addSubview(avatar)
+        chip.addSubview(textStack)
+        NSLayoutConstraint.activate([
+            avatar.leadingAnchor.constraint(equalTo: chip.leadingAnchor, constant: 8),
+            avatar.centerYAnchor.constraint(equalTo: chip.centerYAnchor),
+            textStack.leadingAnchor.constraint(equalTo: avatar.trailingAnchor, constant: 7),
+            textStack.trailingAnchor.constraint(equalTo: chip.trailingAnchor, constant: -8),
+            textStack.centerYAnchor.constraint(equalTo: chip.centerYAnchor)
+        ])
+
+        scheduleProfileNameLabel = name
+        scheduleProfileEmailLabel = email
+        scheduleProfileImageView = avatar
+        return chip
+    }
+
     private func makeScheduleRefreshButton() -> NSButton {
         let button = NSButton(title: "", target: self, action: #selector(scheduleReloadButtonPressed(_:)))
         button.translatesAutoresizingMaskIntoConstraints = false
@@ -2772,6 +2838,8 @@ private extension ViewController {
         do {
             if googleOAuth.loadTokens() == nil {
                 await MainActor.run {
+                    updateGoogleAuthButtonTitle()
+                    applyGoogleProfile(nil)
                     scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
                     if let stack = scheduleCardsStack {
                         renderScheduleCards(into: stack, meetings: [])
@@ -2781,10 +2849,13 @@ private extension ViewController {
             }
 
             let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
+            let profile = try? await googleOAuth.fetchUserProfile(accessToken: token)
             let meetings = try await calendarClient.fetchUpcomingMeetings(accessToken: token)
             let filtered = filteredMeetings(meetings)
 
             await MainActor.run {
+                updateGoogleAuthButtonTitle()
+                applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) })
                 scheduleDateHeadingLabel?.stringValue = scheduleHeadingText(for: filtered)
                 if let stack = scheduleCardsStack {
                     renderScheduleCards(into: stack, meetings: filtered)
@@ -2792,6 +2863,10 @@ private extension ViewController {
             }
         } catch {
             await MainActor.run {
+                updateGoogleAuthButtonTitle()
+                if googleOAuth.loadTokens() == nil {
+                    applyGoogleProfile(nil)
+                }
                 scheduleDateHeadingLabel?.stringValue = "Couldn’t load schedule"
                 if let stack = scheduleCardsStack {
                     renderScheduleCards(into: stack, meetings: [])
@@ -2813,17 +2888,17 @@ private extension ViewController {
         Task { [weak self] in
             guard let self else { return }
             do {
-                try await ensureGoogleClientIdConfigured(presentingWindow: view.window)
-                _ = try await googleOAuth.validAccessToken(presentingWindow: view.window)
+                try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window)
+                _ = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
                 await MainActor.run {
-                    scheduleDateHeadingLabel?.stringValue = "Refreshing…"
-                    pageCache[.joinMeetings] = nil
-                    showSidebarPage(.joinMeetings)
+                    self.scheduleDateHeadingLabel?.stringValue = "Refreshing…"
+                    self.pageCache[.joinMeetings] = nil
+                    self.showSidebarPage(.joinMeetings)
                 }
-                await loadSchedule()
+                await self.loadSchedule()
             } catch {
                 await MainActor.run {
-                    showSimpleError("Couldn’t refresh schedule.", error: error)
+                    self.showSimpleError("Couldn’t refresh schedule.", error: error)
                 }
             }
         }
@@ -2833,12 +2908,83 @@ private extension ViewController {
         Task { [weak self] in
             guard let self else { return }
             do {
-                try await ensureGoogleClientIdConfigured(presentingWindow: view.window)
-                _ = try await googleOAuth.validAccessToken(presentingWindow: view.window)
-                pageCache[.joinMeetings] = nil
-                showSidebarPage(.joinMeetings)
+                if self.googleOAuth.loadTokens() != nil {
+                    try self.googleOAuth.signOut()
+                    await MainActor.run {
+                        self.updateGoogleAuthButtonTitle()
+                        self.applyGoogleProfile(nil)
+                        self.scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
+                        if let stack = self.scheduleCardsStack {
+                            self.renderScheduleCards(into: stack, meetings: [])
+                        }
+                    }
+                    return
+                }
+
+                try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window)
+                let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
+                let profile = try? await self.googleOAuth.fetchUserProfile(accessToken: token)
+                await MainActor.run {
+                    self.updateGoogleAuthButtonTitle()
+                    self.applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) })
+                    self.pageCache[.joinMeetings] = nil
+                    self.showSidebarPage(.joinMeetings)
+                }
+            } catch {
+                self.showSimpleError("Couldn’t connect Google account.", error: error)
+            }
+        }
+    }
+
+    private func updateGoogleAuthButtonTitle() {
+        let signedIn = (googleOAuth.loadTokens() != nil)
+        scheduleGoogleAuthButton?.title = signedIn ? "Logout" : "Login with Google"
+        scheduleGoogleAuthButton?.image = NSImage(systemSymbolName: signedIn ? "rectangle.portrait.and.arrow.right" : "person.crop.circle.badge.checkmark", accessibilityDescription: "Google Auth")
+        scheduleGoogleAuthButton?.imagePosition = .imageLeading
+        scheduleGoogleAuthButton?.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
+    }
+
+    private func makeGoogleProfileDisplay(from profile: GoogleUserProfile) -> GoogleProfileDisplay {
+        let cleanedName = profile.name?.trimmingCharacters(in: .whitespacesAndNewlines)
+        let cleanedEmail = profile.email?.trimmingCharacters(in: .whitespacesAndNewlines)
+        return GoogleProfileDisplay(
+            name: (cleanedName?.isEmpty == false ? cleanedName : nil) ?? "Google User",
+            email: (cleanedEmail?.isEmpty == false ? cleanedEmail : nil) ?? "Signed in",
+            pictureURL: profile.picture.flatMap(URL.init(string:))
+        )
+    }
+
+    private func applyGoogleProfile(_ profile: GoogleProfileDisplay?) {
+        scheduleProfileImageTask?.cancel()
+        scheduleProfileImageTask = nil
+
+        guard let profile else {
+            scheduleProfileContainer?.isHidden = true
+            scheduleProfileNameLabel?.stringValue = "Google User"
+            scheduleProfileEmailLabel?.stringValue = "Signed in"
+            scheduleProfileImageView?.image = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Profile")
+            scheduleProfileImageView?.contentTintColor = palette.textSecondary
+            return
+        }
+
+        scheduleProfileContainer?.isHidden = false
+        scheduleProfileNameLabel?.stringValue = profile.name
+        scheduleProfileEmailLabel?.stringValue = profile.email
+        scheduleProfileImageView?.image = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Profile")
+        scheduleProfileImageView?.contentTintColor = palette.textSecondary
+
+        guard let pictureURL = profile.pictureURL else { return }
+        scheduleProfileImageTask = Task { [weak self] in
+            do {
+                let (data, _) = try await URLSession.shared.data(from: pictureURL)
+                if Task.isCancelled { return }
+                guard let image = NSImage(data: data) else { return }
+                await MainActor.run {
+                    self?.scheduleProfileImageView?.image = image
+                    self?.scheduleProfileImageView?.contentTintColor = nil
+                }
             } catch {
-                showSimpleError("Couldn’t connect Google account.", error: error)
+                // Keep placeholder avatar if image fetch fails.
             }
         }
     }