Преглед на файлове

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 преди 1 седмица
родител
ревизия
d7f04359e6
променени са 3 файла, в които са добавени 194 реда и са изтрити 68 реда
  1. 23 1
      meetings_app/Auth/GoogleOAuthService.swift
  2. 11 53
      meetings_app/Auth/KeychainTokenStore.swift
  3. 160 14
      meetings_app/ViewController.swift

+ 23 - 1
meetings_app/Auth/GoogleOAuthService.swift

@@ -11,6 +11,12 @@ struct GoogleOAuthTokens: Codable, Equatable {
11 11
     var tokenType: String?
12 12
 }
13 13
 
14
+struct GoogleUserProfile: Codable, Equatable {
15
+    var name: String?
16
+    var email: String?
17
+    var picture: String?
18
+}
19
+
14 20
 enum GoogleOAuthError: Error {
15 21
     case missingClientId
16 22
     case missingClientSecret
@@ -32,8 +38,11 @@ final class GoogleOAuthService: NSObject {
32 38
     private let bundledClientId = "824412072260-m9g5g6mlemnb0o079rtuqnh0e1unmelc.apps.googleusercontent.com"
33 39
     private let bundledClientSecret = "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"
34 40
 
35
-    // Calendar readonly is needed for the Schedule list. Meet scopes can be added later when needed.
41
+    // Calendar is needed for schedule. Profile/email make login feel complete in-app.
36 42
     private let scopes = [
43
+        "openid",
44
+        "email",
45
+        "profile",
37 46
         "https://www.googleapis.com/auth/calendar.readonly"
38 47
     ]
39 48
 
@@ -68,6 +77,19 @@ final class GoogleOAuthService: NSObject {
68 77
         try? tokenStore.readTokens()
69 78
     }
70 79
 
80
+    func fetchUserProfile(accessToken: String) async throws -> GoogleUserProfile {
81
+        var request = URLRequest(url: URL(string: "https://openidconnect.googleapis.com/v1/userinfo")!)
82
+        request.httpMethod = "GET"
83
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
84
+
85
+        let (data, response) = try await URLSession.shared.data(for: request)
86
+        guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
87
+            let details = String(data: data, encoding: .utf8) ?? "HTTP \((response as? HTTPURLResponse)?.statusCode ?? -1)"
88
+            throw GoogleOAuthError.tokenExchangeFailed(details)
89
+        }
90
+        return try JSONDecoder().decode(GoogleUserProfile.self, from: data)
91
+    }
92
+
71 93
     func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
72 94
         if var tokens = try tokenStore.readTokens() {
73 95
             if tokens.expiresAt.timeIntervalSinceNow > 60 {

+ 11 - 53
meetings_app/Auth/KeychainTokenStore.swift

@@ -1,72 +1,30 @@
1 1
 import Foundation
2
-import Security
3
-
4
-enum KeychainTokenStoreError: Error {
5
-    case unexpectedStatus(OSStatus)
6
-    case missingData
7
-}
8 2
 
3
+/// Keeps the existing API surface while storing OAuth tokens in UserDefaults.
4
+/// This avoids macOS keychain unlock prompts during development/test runs.
9 5
 final class KeychainTokenStore {
10
-    private let service: String
11
-    private let account: String
6
+    private let defaultsKey: String
7
+    private let defaults: UserDefaults
12 8
 
13 9
     init(service: String = Bundle.main.bundleIdentifier ?? "meetings_app",
14
-         account: String = "googleOAuthTokens") {
15
-        self.service = service
16
-        self.account = account
10
+         account: String = "googleOAuthTokens",
11
+         defaults: UserDefaults = .standard) {
12
+        self.defaultsKey = "\(service).\(account)"
13
+        self.defaults = defaults
17 14
     }
18 15
 
19 16
     func readTokens() throws -> GoogleOAuthTokens? {
20
-        let query: [String: Any] = [
21
-            kSecClass as String: kSecClassGenericPassword,
22
-            kSecAttrService as String: service,
23
-            kSecAttrAccount as String: account,
24
-            kSecMatchLimit as String: kSecMatchLimitOne,
25
-            kSecReturnData as String: true
26
-        ]
27
-
28
-        // Access group / accessibility can be set here if needed later.
29
-        var item: CFTypeRef?
30
-        let status = SecItemCopyMatching(query as CFDictionary, &item)
31
-        if status == errSecItemNotFound { return nil }
32
-        guard status == errSecSuccess else { throw KeychainTokenStoreError.unexpectedStatus(status) }
33
-        guard let data = item as? Data else { throw KeychainTokenStoreError.missingData }
17
+        guard let data = defaults.data(forKey: defaultsKey) else { return nil }
34 18
         return try JSONDecoder().decode(GoogleOAuthTokens.self, from: data)
35 19
     }
36 20
 
37 21
     func writeTokens(_ tokens: GoogleOAuthTokens) throws {
38 22
         let data = try JSONEncoder().encode(tokens)
39
-
40
-        // Upsert.
41
-        let query: [String: Any] = [
42
-            kSecClass as String: kSecClassGenericPassword,
43
-            kSecAttrService as String: service,
44
-            kSecAttrAccount as String: account
45
-        ]
46
-
47
-        let attributes: [String: Any] = [
48
-            kSecValueData as String: data
49
-        ]
50
-
51
-        let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
52
-        if status == errSecSuccess { return }
53
-        if status != errSecItemNotFound { throw KeychainTokenStoreError.unexpectedStatus(status) }
54
-
55
-        var add = query
56
-        add[kSecValueData as String] = data
57
-        let addStatus = SecItemAdd(add as CFDictionary, nil)
58
-        guard addStatus == errSecSuccess else { throw KeychainTokenStoreError.unexpectedStatus(addStatus) }
23
+        defaults.set(data, forKey: defaultsKey)
59 24
     }
60 25
 
61 26
     func deleteTokens() throws {
62
-        let query: [String: Any] = [
63
-            kSecClass as String: kSecClassGenericPassword,
64
-            kSecAttrService as String: service,
65
-            kSecAttrAccount as String: account
66
-        ]
67
-        let status = SecItemDelete(query as CFDictionary)
68
-        if status == errSecSuccess || status == errSecItemNotFound { return }
69
-        throw KeychainTokenStoreError.unexpectedStatus(status)
27
+        defaults.removeObject(forKey: defaultsKey)
70 28
     }
71 29
 }
72 30
 

+ 160 - 14
meetings_app/ViewController.swift

@@ -38,6 +38,12 @@ private enum PremiumPlan: Int {
38 38
 }
39 39
 
40 40
 final class ViewController: NSViewController {
41
+    private struct GoogleProfileDisplay {
42
+        let name: String
43
+        let email: String
44
+        let pictureURL: URL?
45
+    }
46
+
41 47
     private var palette = Palette(isDarkMode: true)
42 48
     private let typography = Typography()
43 49
     private let launchContentSize = NSSize(width: 920, height: 690)
@@ -78,6 +84,12 @@ final class ViewController: NSViewController {
78 84
     private weak var scheduleScrollLeftButton: NSView?
79 85
     private weak var scheduleScrollRightButton: NSView?
80 86
     private weak var scheduleFilterDropdown: NSPopUpButton?
87
+    private weak var scheduleGoogleAuthButton: NSButton?
88
+    private weak var scheduleProfileContainer: NSView?
89
+    private weak var scheduleProfileNameLabel: NSTextField?
90
+    private weak var scheduleProfileEmailLabel: NSTextField?
91
+    private weak var scheduleProfileImageView: NSImageView?
92
+    private var scheduleProfileImageTask: Task<Void, Never>?
81 93
 
82 94
     /// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`).
83 95
     private let inAppBrowserDefaultPolicy: InAppBrowserURLPolicy = .allowAll
@@ -1805,9 +1817,15 @@ private extension ViewController {
1805 1817
 
1806 1818
         row.addArrangedSubview(makeScheduleRefreshButton())
1807 1819
 
1808
-        let connectButton = makeSchedulePillButton(title: googleOAuth.loadTokens() == nil ? "Connect" : "Connected")
1820
+        let profileBadge = makeScheduleProfileBadge()
1821
+        scheduleProfileContainer = profileBadge
1822
+        row.addArrangedSubview(profileBadge)
1823
+
1824
+        let connectButton = makeSchedulePillButton(title: googleOAuth.loadTokens() == nil ? "Login with Google" : "Logout")
1809 1825
         connectButton.target = self
1810 1826
         connectButton.action = #selector(scheduleConnectButtonPressed(_:))
1827
+        scheduleGoogleAuthButton = connectButton
1828
+        updateGoogleAuthButtonTitle()
1811 1829
         row.addArrangedSubview(connectButton)
1812 1830
 
1813 1831
         row.addArrangedSubview(makeScheduleFilterDropdown())
@@ -1859,10 +1877,58 @@ private extension ViewController {
1859 1877
         button.contentTintColor = palette.textSecondary
1860 1878
         button.setButtonType(.momentaryChange)
1861 1879
         button.heightAnchor.constraint(equalToConstant: 34).isActive = true
1862
-        button.widthAnchor.constraint(greaterThanOrEqualToConstant: 104).isActive = true
1880
+        button.widthAnchor.constraint(greaterThanOrEqualToConstant: 132).isActive = true
1863 1881
         return button
1864 1882
     }
1865 1883
 
1884
+    private func makeScheduleProfileBadge() -> NSView {
1885
+        let chip = roundedContainer(cornerRadius: 16, color: palette.inputBackground)
1886
+        chip.translatesAutoresizingMaskIntoConstraints = false
1887
+        styleSurface(chip, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
1888
+        chip.heightAnchor.constraint(equalToConstant: 34).isActive = true
1889
+        chip.widthAnchor.constraint(equalToConstant: 230).isActive = true
1890
+        chip.isHidden = true
1891
+
1892
+        let avatar = NSImageView()
1893
+        avatar.translatesAutoresizingMaskIntoConstraints = false
1894
+        avatar.wantsLayer = true
1895
+        avatar.layer?.cornerRadius = 11
1896
+        avatar.layer?.masksToBounds = true
1897
+        avatar.imageScaling = .scaleAxesIndependently
1898
+        avatar.widthAnchor.constraint(equalToConstant: 22).isActive = true
1899
+        avatar.heightAnchor.constraint(equalToConstant: 22).isActive = true
1900
+        avatar.image = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Profile")
1901
+        avatar.contentTintColor = palette.textSecondary
1902
+
1903
+        let name = textLabel("Google User", font: NSFont.systemFont(ofSize: 11, weight: .semibold), color: palette.textPrimary)
1904
+        let email = textLabel("Signed in", font: NSFont.systemFont(ofSize: 10, weight: .regular), color: palette.textMuted)
1905
+        name.lineBreakMode = .byTruncatingTail
1906
+        email.lineBreakMode = .byTruncatingTail
1907
+        name.maximumNumberOfLines = 1
1908
+        email.maximumNumberOfLines = 1
1909
+
1910
+        let textStack = NSStackView(views: [name, email])
1911
+        textStack.translatesAutoresizingMaskIntoConstraints = false
1912
+        textStack.orientation = .vertical
1913
+        textStack.spacing = 0
1914
+        textStack.alignment = .leading
1915
+
1916
+        chip.addSubview(avatar)
1917
+        chip.addSubview(textStack)
1918
+        NSLayoutConstraint.activate([
1919
+            avatar.leadingAnchor.constraint(equalTo: chip.leadingAnchor, constant: 8),
1920
+            avatar.centerYAnchor.constraint(equalTo: chip.centerYAnchor),
1921
+            textStack.leadingAnchor.constraint(equalTo: avatar.trailingAnchor, constant: 7),
1922
+            textStack.trailingAnchor.constraint(equalTo: chip.trailingAnchor, constant: -8),
1923
+            textStack.centerYAnchor.constraint(equalTo: chip.centerYAnchor)
1924
+        ])
1925
+
1926
+        scheduleProfileNameLabel = name
1927
+        scheduleProfileEmailLabel = email
1928
+        scheduleProfileImageView = avatar
1929
+        return chip
1930
+    }
1931
+
1866 1932
     private func makeScheduleRefreshButton() -> NSButton {
1867 1933
         let button = NSButton(title: "", target: self, action: #selector(scheduleReloadButtonPressed(_:)))
1868 1934
         button.translatesAutoresizingMaskIntoConstraints = false
@@ -2772,6 +2838,8 @@ private extension ViewController {
2772 2838
         do {
2773 2839
             if googleOAuth.loadTokens() == nil {
2774 2840
                 await MainActor.run {
2841
+                    updateGoogleAuthButtonTitle()
2842
+                    applyGoogleProfile(nil)
2775 2843
                     scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
2776 2844
                     if let stack = scheduleCardsStack {
2777 2845
                         renderScheduleCards(into: stack, meetings: [])
@@ -2781,10 +2849,13 @@ private extension ViewController {
2781 2849
             }
2782 2850
 
2783 2851
             let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
2852
+            let profile = try? await googleOAuth.fetchUserProfile(accessToken: token)
2784 2853
             let meetings = try await calendarClient.fetchUpcomingMeetings(accessToken: token)
2785 2854
             let filtered = filteredMeetings(meetings)
2786 2855
 
2787 2856
             await MainActor.run {
2857
+                updateGoogleAuthButtonTitle()
2858
+                applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) })
2788 2859
                 scheduleDateHeadingLabel?.stringValue = scheduleHeadingText(for: filtered)
2789 2860
                 if let stack = scheduleCardsStack {
2790 2861
                     renderScheduleCards(into: stack, meetings: filtered)
@@ -2792,6 +2863,10 @@ private extension ViewController {
2792 2863
             }
2793 2864
         } catch {
2794 2865
             await MainActor.run {
2866
+                updateGoogleAuthButtonTitle()
2867
+                if googleOAuth.loadTokens() == nil {
2868
+                    applyGoogleProfile(nil)
2869
+                }
2795 2870
                 scheduleDateHeadingLabel?.stringValue = "Couldn’t load schedule"
2796 2871
                 if let stack = scheduleCardsStack {
2797 2872
                     renderScheduleCards(into: stack, meetings: [])
@@ -2813,17 +2888,17 @@ private extension ViewController {
2813 2888
         Task { [weak self] in
2814 2889
             guard let self else { return }
2815 2890
             do {
2816
-                try await ensureGoogleClientIdConfigured(presentingWindow: view.window)
2817
-                _ = try await googleOAuth.validAccessToken(presentingWindow: view.window)
2891
+                try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window)
2892
+                _ = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
2818 2893
                 await MainActor.run {
2819
-                    scheduleDateHeadingLabel?.stringValue = "Refreshing…"
2820
-                    pageCache[.joinMeetings] = nil
2821
-                    showSidebarPage(.joinMeetings)
2894
+                    self.scheduleDateHeadingLabel?.stringValue = "Refreshing…"
2895
+                    self.pageCache[.joinMeetings] = nil
2896
+                    self.showSidebarPage(.joinMeetings)
2822 2897
                 }
2823
-                await loadSchedule()
2898
+                await self.loadSchedule()
2824 2899
             } catch {
2825 2900
                 await MainActor.run {
2826
-                    showSimpleError("Couldn’t refresh schedule.", error: error)
2901
+                    self.showSimpleError("Couldn’t refresh schedule.", error: error)
2827 2902
                 }
2828 2903
             }
2829 2904
         }
@@ -2833,12 +2908,83 @@ private extension ViewController {
2833 2908
         Task { [weak self] in
2834 2909
             guard let self else { return }
2835 2910
             do {
2836
-                try await ensureGoogleClientIdConfigured(presentingWindow: view.window)
2837
-                _ = try await googleOAuth.validAccessToken(presentingWindow: view.window)
2838
-                pageCache[.joinMeetings] = nil
2839
-                showSidebarPage(.joinMeetings)
2911
+                if self.googleOAuth.loadTokens() != nil {
2912
+                    try self.googleOAuth.signOut()
2913
+                    await MainActor.run {
2914
+                        self.updateGoogleAuthButtonTitle()
2915
+                        self.applyGoogleProfile(nil)
2916
+                        self.scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
2917
+                        if let stack = self.scheduleCardsStack {
2918
+                            self.renderScheduleCards(into: stack, meetings: [])
2919
+                        }
2920
+                    }
2921
+                    return
2922
+                }
2923
+
2924
+                try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window)
2925
+                let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
2926
+                let profile = try? await self.googleOAuth.fetchUserProfile(accessToken: token)
2927
+                await MainActor.run {
2928
+                    self.updateGoogleAuthButtonTitle()
2929
+                    self.applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) })
2930
+                    self.pageCache[.joinMeetings] = nil
2931
+                    self.showSidebarPage(.joinMeetings)
2932
+                }
2933
+            } catch {
2934
+                self.showSimpleError("Couldn’t connect Google account.", error: error)
2935
+            }
2936
+        }
2937
+    }
2938
+
2939
+    private func updateGoogleAuthButtonTitle() {
2940
+        let signedIn = (googleOAuth.loadTokens() != nil)
2941
+        scheduleGoogleAuthButton?.title = signedIn ? "Logout" : "Login with Google"
2942
+        scheduleGoogleAuthButton?.image = NSImage(systemSymbolName: signedIn ? "rectangle.portrait.and.arrow.right" : "person.crop.circle.badge.checkmark", accessibilityDescription: "Google Auth")
2943
+        scheduleGoogleAuthButton?.imagePosition = .imageLeading
2944
+        scheduleGoogleAuthButton?.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
2945
+    }
2946
+
2947
+    private func makeGoogleProfileDisplay(from profile: GoogleUserProfile) -> GoogleProfileDisplay {
2948
+        let cleanedName = profile.name?.trimmingCharacters(in: .whitespacesAndNewlines)
2949
+        let cleanedEmail = profile.email?.trimmingCharacters(in: .whitespacesAndNewlines)
2950
+        return GoogleProfileDisplay(
2951
+            name: (cleanedName?.isEmpty == false ? cleanedName : nil) ?? "Google User",
2952
+            email: (cleanedEmail?.isEmpty == false ? cleanedEmail : nil) ?? "Signed in",
2953
+            pictureURL: profile.picture.flatMap(URL.init(string:))
2954
+        )
2955
+    }
2956
+
2957
+    private func applyGoogleProfile(_ profile: GoogleProfileDisplay?) {
2958
+        scheduleProfileImageTask?.cancel()
2959
+        scheduleProfileImageTask = nil
2960
+
2961
+        guard let profile else {
2962
+            scheduleProfileContainer?.isHidden = true
2963
+            scheduleProfileNameLabel?.stringValue = "Google User"
2964
+            scheduleProfileEmailLabel?.stringValue = "Signed in"
2965
+            scheduleProfileImageView?.image = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Profile")
2966
+            scheduleProfileImageView?.contentTintColor = palette.textSecondary
2967
+            return
2968
+        }
2969
+
2970
+        scheduleProfileContainer?.isHidden = false
2971
+        scheduleProfileNameLabel?.stringValue = profile.name
2972
+        scheduleProfileEmailLabel?.stringValue = profile.email
2973
+        scheduleProfileImageView?.image = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Profile")
2974
+        scheduleProfileImageView?.contentTintColor = palette.textSecondary
2975
+
2976
+        guard let pictureURL = profile.pictureURL else { return }
2977
+        scheduleProfileImageTask = Task { [weak self] in
2978
+            do {
2979
+                let (data, _) = try await URLSession.shared.data(from: pictureURL)
2980
+                if Task.isCancelled { return }
2981
+                guard let image = NSImage(data: data) else { return }
2982
+                await MainActor.run {
2983
+                    self?.scheduleProfileImageView?.image = image
2984
+                    self?.scheduleProfileImageView?.contentTintColor = nil
2985
+                }
2840 2986
             } catch {
2841
-                showSimpleError("Couldn’t connect Google account.", error: error)
2987
+                // Keep placeholder avatar if image fetch fails.
2842 2988
             }
2843 2989
         }
2844 2990
     }