Просмотр исходного кода

Add Zoom OAuth login flow and scheduled meetings integration.

Wire Zoom as the primary sign-in path with browser OAuth token handling, refresh support, and meetings list rendering so users can authenticate and load scheduled meetings end-to-end.

Made-with: Cursor
huzaifahayat12 3 месяцев назад
Родитель
Сommit
02428aa6dc
2 измененных файлов с 587 добавлено и 98 удалено
  1. 2 0
      zoom_app.xcodeproj/project.pbxproj
  2. 585 98
      zoom_app/ViewController.swift

+ 2 - 0
zoom_app.xcodeproj/project.pbxproj

@@ -258,6 +258,7 @@
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
+				INFOPLIST_KEY_ZoomOAuthClientId = "isvIAKPhSPOhBxFUkiY2A";
 				LD_RUNPATH_SEARCH_PATHS = (
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"$(inherited)",
 					"@executable_path/../Frameworks",
 					"@executable_path/../Frameworks",
@@ -290,6 +291,7 @@
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
+				INFOPLIST_KEY_ZoomOAuthClientId = "isvIAKPhSPOhBxFUkiY2A";
 				LD_RUNPATH_SEARCH_PATHS = (
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"$(inherited)",
 					"@executable_path/../Frameworks",
 					"@executable_path/../Frameworks",

+ 585 - 98
zoom_app/ViewController.swift

@@ -12,6 +12,7 @@ import WebKit
 
 
 class ViewController: NSViewController {
 class ViewController: NSViewController {
     private let googleOAuth = GoogleOAuthService.shared
     private let googleOAuth = GoogleOAuthService.shared
+    private let zoomOAuth = ZoomOAuthService.shared
     private let sidebarWidth: CGFloat = 78
     private let sidebarWidth: CGFloat = 78
     private let appBackground = NSColor(calibratedRed: 10 / 255, green: 11 / 255, blue: 12 / 255, alpha: 1)
     private let appBackground = NSColor(calibratedRed: 10 / 255, green: 11 / 255, blue: 12 / 255, alpha: 1)
     private let sidebarBackground = NSColor(calibratedRed: 16 / 255, green: 17 / 255, blue: 19 / 255, alpha: 1)
     private let sidebarBackground = NSColor(calibratedRed: 16 / 255, green: 17 / 255, blue: 19 / 255, alpha: 1)
@@ -28,15 +29,16 @@ class ViewController: NSViewController {
     private var loginView: NSView?
     private var loginView: NSView?
     private var homeView: NSView?
     private var homeView: NSView?
     private weak var googleButton: NSButton?
     private weak var googleButton: NSButton?
+    private weak var nextSignInButton: NSButton?
+    private weak var zoomSocialButton: NSButton?
     private weak var timeLabel: NSTextField?
     private weak var timeLabel: NSTextField?
     private weak var dateLabel: NSTextField?
     private weak var dateLabel: NSTextField?
-    private weak var meetingTitleLabel: NSTextField?
-    private weak var meetingDetailLabel: NSTextField?
-    private weak var meetingHostLabel: NSTextField?
     private weak var emptyMeetingLabel: NSTextField?
     private weak var emptyMeetingLabel: NSTextField?
-    private weak var meetingCard: NSView?
+    private weak var meetingsListStack: NSStackView?
+    private weak var meetingsStatusLabel: NSTextField?
     private var clockTimer: Timer?
     private var clockTimer: Timer?
     private var isSigningIn = false
     private var isSigningIn = false
+    private var isPromptingZoomCredentials = false
 
 
     override func viewDidLoad() {
     override func viewDidLoad() {
         super.viewDidLoad()
         super.viewDidLoad()
@@ -68,6 +70,10 @@ class ViewController: NSViewController {
         clockTimer?.invalidate()
         clockTimer?.invalidate()
         homeView?.removeFromSuperview()
         homeView?.removeFromSuperview()
         homeView = nil
         homeView = nil
+        isSigningIn = false
+        nextSignInButton?.title = "Next"
+        nextSignInButton?.isEnabled = true
+        zoomSocialButton?.isEnabled = true
 
 
         if loginView == nil {
         if loginView == nil {
             loginView = makeLoginView()
             loginView = makeLoginView()
@@ -125,6 +131,58 @@ class ViewController: NSViewController {
         }
         }
     }
     }
 
 
+    /// Primary Zoom sign-in: browser OAuth, token refresh, then home with scheduled meetings.
+    @objc private func zoomPrimarySignInTapped() {
+        guard isSigningIn == false else { return }
+        isSigningIn = true
+        nextSignInButton?.title = "Signing in…"
+        nextSignInButton?.isEnabled = false
+        zoomSocialButton?.isEnabled = false
+        googleButton?.isEnabled = false
+
+        Task {
+            do {
+                let configured = await MainActor.run { self.ensureZoomOAuthClientConfigured() }
+                guard configured else {
+                    await MainActor.run { self.resetLoginSigningInState() }
+                    return
+                }
+
+                let zoomToken = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
+                let zoomUser = try? await fetchZoomUserProfile(accessToken: zoomToken)
+                let profile = zoomUser.map { GoogleUserProfile(name: $0.displayName, email: $0.email, picture: $0.pictureURL) }
+
+                await MainActor.run {
+                    self.resetLoginSigningInState()
+                    self.showHomeView(profile: profile)
+                }
+            } catch {
+                await MainActor.run {
+                    self.resetLoginSigningInState()
+                    self.showSimpleError("Zoom sign-in failed", error: error)
+                }
+            }
+        }
+    }
+
+    @MainActor
+    private func resetLoginSigningInState() {
+        isSigningIn = false
+        nextSignInButton?.title = "Next"
+        nextSignInButton?.isEnabled = true
+        zoomSocialButton?.isEnabled = true
+        googleButton?.isEnabled = true
+    }
+
+    /// Returns false if the user cancelled or left credentials empty.
+    @MainActor
+    private func ensureZoomOAuthClientConfigured() -> Bool {
+        if zoomOAuth.configuredClientId() != nil, zoomOAuth.configuredClientSecret() != nil {
+            return true
+        }
+        return presentZoomOAuthCredentialPrompt()
+    }
+
     private func showSimpleError(_ title: String, error: Error) {
     private func showSimpleError(_ title: String, error: Error) {
         let alert = NSAlert()
         let alert = NSAlert()
         alert.alertStyle = .warning
         alert.alertStyle = .warning
@@ -138,91 +196,210 @@ class ViewController: NSViewController {
         let start: Date
         let start: Date
         let end: Date?
         let end: Date?
         let host: String
         let host: String
+        let source: String
     }
     }
 
 
     @MainActor
     @MainActor
-    private func applyMeeting(_ meeting: ScheduledMeeting?) {
-        if let meeting {
-            let dateFormatter = DateFormatter()
-            dateFormatter.dateFormat = "EEE, MMM d"
-            let timeFormatter = DateFormatter()
-            timeFormatter.dateFormat = "h:mm a"
-            let startText = timeFormatter.string(from: meeting.start)
-            let endText = meeting.end.map { timeFormatter.string(from: $0) } ?? ""
-            let range = endText.isEmpty ? startText : "\(startText) - \(endText)"
-
-            meetingTitleLabel?.stringValue = meeting.title
-            meetingDetailLabel?.stringValue = "\(dateFormatter.string(from: meeting.start))\n\(range)"
-            meetingHostLabel?.stringValue = "Host: \(meeting.host)"
-            meetingCard?.isHidden = false
-            emptyMeetingLabel?.isHidden = true
-        } else {
-            meetingCard?.isHidden = true
+    private func applyMeetings(_ meetings: [ScheduledMeeting]) {
+        guard let stack = meetingsListStack else { return }
+        stack.arrangedSubviews.forEach { view in
+            stack.removeArrangedSubview(view)
+            view.removeFromSuperview()
+        }
+
+        let ordered = meetings.sorted(by: { $0.start < $1.start })
+        if ordered.isEmpty {
             emptyMeetingLabel?.isHidden = false
             emptyMeetingLabel?.isHidden = false
+            meetingsStatusLabel?.stringValue = "No upcoming Zoom meetings found."
+            return
+        }
+
+        emptyMeetingLabel?.isHidden = true
+        meetingsStatusLabel?.stringValue = "Zoom meetings"
+        for meeting in ordered {
+            stack.addArrangedSubview(makeMeetingRowCard(meeting))
         }
         }
     }
     }
 
 
     private func loadScheduledMeetings() async {
     private func loadScheduledMeetings() async {
         do {
         do {
-            let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
-            let meetings = try await fetchGoogleScheduledMeetings(accessToken: token)
+            let zoomToken = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
+            let zoomMeetings = try await fetchZoomScheduledMeetings(accessToken: zoomToken)
             await MainActor.run {
             await MainActor.run {
-                self.applyMeeting(meetings.first)
+                self.applyMeetings(zoomMeetings)
             }
             }
         } catch {
         } catch {
             await MainActor.run {
             await MainActor.run {
-                self.applyMeeting(nil)
+                self.applyMeetings([])
+                if case ZoomOAuthError.missingClientId = error {
+                    self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
+                    self.promptForZoomOAuthCredentialsIfNeeded()
+                } else if case ZoomOAuthError.missingClientSecret = error {
+                    self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
+                    self.promptForZoomOAuthCredentialsIfNeeded()
+                } else if case ZoomOAuthError.missingRequiredScope(let scopeMessage) = error {
+                    self.zoomOAuth.clearSavedTokens()
+                    self.meetingsStatusLabel?.stringValue = "Zoom OAuth scope missing. Add required scopes in Marketplace, click Add app now, then sign in again. (\(scopeMessage))"
+                } else {
+                    self.meetingsStatusLabel?.stringValue = "Zoom API error: \(error.localizedDescription)"
+                }
             }
             }
         }
         }
     }
     }
 
 
-    private func fetchGoogleScheduledMeetings(accessToken: String) async throws -> [ScheduledMeeting] {
-        var components = URLComponents(string: "https://www.googleapis.com/calendar/v3/calendars/primary/events")!
-        let now = ISO8601DateFormatter().string(from: Date())
-        components.queryItems = [
-            URLQueryItem(name: "singleEvents", value: "true"),
-            URLQueryItem(name: "orderBy", value: "startTime"),
-            URLQueryItem(name: "timeMin", value: now),
-            URLQueryItem(name: "maxResults", value: "10")
-        ]
-        var request = URLRequest(url: components.url!)
+    @MainActor
+    private func presentZoomOAuthCredentialPrompt() -> Bool {
+        let alert = NSAlert()
+        alert.alertStyle = .informational
+        alert.messageText = "Configure Zoom OAuth"
+        alert.informativeText = "Enter your Zoom Marketplace OAuth app Client ID and Client Secret once (or set ZoomOAuthClientId in Info.plist and ZOOM_OAUTH_CLIENT_SECRET in the run environment). After this, sign-in and token refresh run automatically."
+
+        let wrapper = NSStackView()
+        wrapper.orientation = .vertical
+        wrapper.spacing = 8
+        wrapper.translatesAutoresizingMaskIntoConstraints = false
+
+        let clientIdField = NSTextField()
+        clientIdField.placeholderString = "Zoom Client ID"
+        clientIdField.stringValue = zoomOAuth.configuredClientId() ?? ""
+        let clientSecretField = NSSecureTextField()
+        clientSecretField.placeholderString = "Zoom Client Secret"
+        clientSecretField.stringValue = zoomOAuth.configuredClientSecret() ?? ""
+        [clientIdField, clientSecretField].forEach { field in
+            field.translatesAutoresizingMaskIntoConstraints = false
+            field.widthAnchor.constraint(equalToConstant: 420).isActive = true
+            wrapper.addArrangedSubview(field)
+        }
+        alert.accessoryView = wrapper
+
+        alert.addButton(withTitle: "Save")
+        alert.addButton(withTitle: "Cancel")
+
+        let result = alert.runModal()
+        if result == .alertFirstButtonReturn {
+            var clientId = clientIdField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
+            var clientSecret = clientSecretField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
+            if clientId.isEmpty { clientId = zoomOAuth.configuredClientId() ?? "" }
+            if clientSecret.isEmpty { clientSecret = zoomOAuth.configuredClientSecret() ?? "" }
+            if clientId.isEmpty == false, clientSecret.isEmpty == false {
+                zoomOAuth.setClientCredentials(clientId: clientId, clientSecret: clientSecret)
+                return true
+            }
+            meetingsStatusLabel?.stringValue = "Both Zoom OAuth Client ID and Client Secret are required (or set bundled values / ZOOM_OAUTH_CLIENT_SECRET)."
+        }
+        return false
+    }
+
+    @MainActor
+    private func promptForZoomOAuthCredentialsIfNeeded() {
+        guard isPromptingZoomCredentials == false else { return }
+        isPromptingZoomCredentials = true
+        defer { isPromptingZoomCredentials = false }
+
+        if presentZoomOAuthCredentialPrompt() {
+            meetingsStatusLabel?.stringValue = "Configured. Starting Zoom OAuth..."
+            Task { await self.loadScheduledMeetings() }
+        }
+    }
+
+    private struct ZoomUserMeResponse: Decodable {
+        let first_name: String?
+        let last_name: String?
+        let display_name: String?
+        let email: String?
+        let pic_url: String?
+
+        var displayName: String? {
+            if let display_name, display_name.isEmpty == false { return display_name }
+            let parts = [first_name, last_name].compactMap { $0 }.filter { $0.isEmpty == false }
+            return parts.isEmpty ? nil : parts.joined(separator: " ")
+        }
+
+        var pictureURL: String? {
+            guard let pic_url, pic_url.isEmpty == false else { return nil }
+            return pic_url
+        }
+    }
+
+    private func fetchZoomUserProfile(accessToken: String) async throws -> ZoomUserMeResponse {
+        let url = URL(string: "https://api.zoom.us/v2/users/me")!
+        var request = URLRequest(url: url)
         request.httpMethod = "GET"
         request.httpMethod = "GET"
         request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
         request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
 
 
         let (data, response) = try await URLSession.shared.data(for: request)
         let (data, response) = try await URLSession.shared.data(for: request)
         guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
         guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
-            throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed to load meetings")
+            throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed to load Zoom profile")
         }
         }
+        return try JSONDecoder().decode(ZoomUserMeResponse.self, from: data)
+    }
 
 
-        struct APIResponse: Decodable {
-            struct Item: Decodable {
-                struct TimeValue: Decodable { let dateTime: String?; let date: String? }
-                let summary: String?
-                let creator: Creator?
-                struct Creator: Decodable { let displayName: String?; let email: String? }
-                let start: TimeValue
-                let end: TimeValue?
-            }
-            let items: [Item]
+    private func fetchZoomScheduledMeetings(accessToken: String) async throws -> [ScheduledMeeting] {
+        struct ZoomMeeting: Decodable {
+            let topic: String?
+            let start_time: String?
+            let duration: Int?
+            let host_id: String?
         }
         }
 
 
-        let decoded = try JSONDecoder().decode(APIResponse.self, from: data)
-        let iso = ISO8601DateFormatter()
-        let dateOnly = DateFormatter()
-        dateOnly.dateFormat = "yyyy-MM-dd"
-        dateOnly.timeZone = TimeZone.current
+        struct ZoomMeetingsPage: Decodable {
+            let meetings: [ZoomMeeting]
+            let next_page_token: String?
+        }
 
 
-        return decoded.items.compactMap { item in
-            let start = (item.start.dateTime.flatMap { iso.date(from: $0) }) ?? (item.start.date.flatMap { dateOnly.date(from: $0) })
-            let end = item.end?.dateTime.flatMap { iso.date(from: $0) } ?? item.end?.date.flatMap { dateOnly.date(from: $0) }
-            guard let start else { return nil }
-            return ScheduledMeeting(
-                title: item.summary?.isEmpty == false ? item.summary! : "Scheduled meeting",
-                start: start,
-                end: end,
-                host: item.creator?.displayName ?? item.creator?.email ?? "Google Calendar"
-            )
+        let iso = ISO8601DateFormatter()
+        iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+        let fallbackISO = ISO8601DateFormatter()
+        fallbackISO.formatOptions = [.withInternetDateTime]
+
+        func mapMeetings(_ raw: [ZoomMeeting]) -> [ScheduledMeeting] {
+            raw.compactMap { meeting in
+                guard let startRaw = meeting.start_time else { return nil }
+                let start = iso.date(from: startRaw) ?? fallbackISO.date(from: startRaw)
+                guard let start else { return nil }
+                let end = meeting.duration.map { start.addingTimeInterval(TimeInterval($0 * 60)) }
+                return ScheduledMeeting(
+                    title: meeting.topic?.isEmpty == false ? meeting.topic! : "Zoom meeting",
+                    start: start,
+                    end: end,
+                    host: meeting.host_id ?? "Zoom Host",
+                    source: "Zoom"
+                )
+            }
         }
         }
+
+        var allMeetings: [ZoomMeeting] = []
+        var nextPageToken: String?
+        repeat {
+            var components = URLComponents(string: "https://api.zoom.us/v2/users/me/meetings")!
+            var items: [URLQueryItem] = [
+                URLQueryItem(name: "type", value: "scheduled"),
+                URLQueryItem(name: "page_size", value: "30")
+            ]
+            if let nextPageToken, nextPageToken.isEmpty == false {
+                items.append(URLQueryItem(name: "next_page_token", value: nextPageToken))
+            }
+            components.queryItems = items
+            var request = URLRequest(url: components.url!)
+            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 raw = String(data: data, encoding: .utf8) ?? "Failed to load Zoom meetings"
+                if raw.localizedCaseInsensitiveContains("does not contain scopes") {
+                    throw ZoomOAuthError.missingRequiredScope(raw)
+                }
+                throw GoogleOAuthError.tokenExchangeFailed(raw)
+            }
+
+            let decoded = try JSONDecoder().decode(ZoomMeetingsPage.self, from: data)
+            allMeetings.append(contentsOf: decoded.meetings)
+            let token = decoded.next_page_token?.trimmingCharacters(in: .whitespacesAndNewlines)
+            nextPageToken = (token?.isEmpty == false) ? token : nil
+        } while nextPageToken != nil
+
+        return mapMeetings(allMeetings)
     }
     }
 
 
     // MARK: - Login UI
     // MARK: - Login UI
@@ -264,13 +441,13 @@ class ViewController: NSViewController {
         emailField.layer?.backgroundColor = cardBackground.cgColor
         emailField.layer?.backgroundColor = cardBackground.cgColor
         emailField.focusRingType = .none
         emailField.focusRingType = .none
 
 
-        let nextButton = NSButton(title: "Next", target: nil, action: nil)
+        let nextButton = NSButton(title: "Next", target: self, action: #selector(zoomPrimarySignInTapped))
         nextButton.font = .systemFont(ofSize: 20, weight: .semibold)
         nextButton.font = .systemFont(ofSize: 20, weight: .semibold)
         nextButton.isBordered = false
         nextButton.isBordered = false
         nextButton.wantsLayer = true
         nextButton.wantsLayer = true
         nextButton.layer?.cornerRadius = 10
         nextButton.layer?.cornerRadius = 10
         nextButton.layer?.backgroundColor = cardBackground.cgColor
         nextButton.layer?.backgroundColor = cardBackground.cgColor
-        nextButton.contentTintColor = mutedText
+        nextButton.contentTintColor = primaryText
 
 
         let divider = NSBox()
         let divider = NSBox()
         divider.boxType = .separator
         divider.boxType = .separator
@@ -280,10 +457,12 @@ class ViewController: NSViewController {
         let google = makeSocialButton(icon: "G", text: "Google", action: #selector(googleLoginTapped))
         let google = makeSocialButton(icon: "G", text: "Google", action: #selector(googleLoginTapped))
         let apple = makeSocialButton(icon: "", text: "Apple")
         let apple = makeSocialButton(icon: "", text: "Apple")
         let facebook = makeSocialButton(icon: "f", text: "Facebook")
         let facebook = makeSocialButton(icon: "f", text: "Facebook")
-        let microsoft = makeSocialButton(icon: "■", text: "Microsoft")
+        let zoomSocial = makeSocialButton(icon: "Z", text: "Zoom", action: #selector(zoomPrimarySignInTapped))
         self.googleButton = google.button
         self.googleButton = google.button
+        self.nextSignInButton = nextButton
+        self.zoomSocialButton = zoomSocial.button
 
 
-        let social = NSStackView(views: [sso.container, google.container, apple.container, facebook.container, microsoft.container])
+        let social = NSStackView(views: [sso.container, google.container, apple.container, facebook.container, zoomSocial.container])
         social.orientation = .horizontal
         social.orientation = .horizontal
         social.spacing = 14
         social.spacing = 14
         social.distribution = .fillEqually
         social.distribution = .fillEqually
@@ -382,16 +561,19 @@ class ViewController: NSViewController {
         panel.layer?.cornerRadius = 14
         panel.layer?.cornerRadius = 14
 
 
         let panelHeader = makeLabel("Today, Apr 14", size: 32, color: primaryText, weight: .semibold, centered: true)
         let panelHeader = makeLabel("Today, Apr 14", size: 32, color: primaryText, weight: .semibold, centered: true)
+        let meetingsStatus = makeLabel("Zoom meetings", size: 12, color: secondaryText, weight: .regular, centered: false)
         let noMeeting = makeLabel("No meetings scheduled.", size: 32, color: secondaryText, weight: .regular, centered: true)
         let noMeeting = makeLabel("No meetings scheduled.", size: 32, color: secondaryText, weight: .regular, centered: true)
-        let scheduledMeetingCard = NSView()
-        scheduledMeetingCard.wantsLayer = true
-        scheduledMeetingCard.layer?.backgroundColor = NSColor(calibratedRed: 35 / 255, green: 40 / 255, blue: 56 / 255, alpha: 1).cgColor
-        scheduledMeetingCard.layer?.cornerRadius = 16
-
-        let meetingTitle = makeLabel("Scheduled meeting", size: 35, color: primaryText, weight: .semibold, centered: false)
-        let meetingDetails = makeLabel("Today\n--:--", size: 22, color: secondaryText, weight: .regular, centered: false)
-        meetingDetails.maximumNumberOfLines = 2
-        let meetingHost = makeLabel("Host: Google Calendar", size: 20, color: secondaryText, weight: .regular, centered: false)
+        let meetingsScrollView = NSScrollView()
+        meetingsScrollView.drawsBackground = false
+        meetingsScrollView.hasVerticalScroller = true
+        meetingsScrollView.hasHorizontalScroller = false
+        meetingsScrollView.autohidesScrollers = true
+
+        let meetingsDocument = NSView()
+        let meetingsStack = NSStackView()
+        meetingsStack.orientation = .vertical
+        meetingsStack.spacing = 10
+        meetingsStack.alignment = .leading
         let openRecordings = makeLabel("Open recordings  ›", size: 30, color: secondaryText, weight: .regular, centered: false)
         let openRecordings = makeLabel("Open recordings  ›", size: 30, color: secondaryText, weight: .regular, centered: false)
         openRecordings.wantsLayer = true
         openRecordings.wantsLayer = true
         openRecordings.layer?.backgroundColor = NSColor(calibratedRed: 31 / 255, green: 33 / 255, blue: 39 / 255, alpha: 1).cgColor
         openRecordings.layer?.backgroundColor = NSColor(calibratedRed: 31 / 255, green: 33 / 255, blue: 39 / 255, alpha: 1).cgColor
@@ -400,14 +582,14 @@ class ViewController: NSViewController {
         contentColumn.translatesAutoresizingMaskIntoConstraints = false
         contentColumn.translatesAutoresizingMaskIntoConstraints = false
         content.addSubview(contentColumn)
         content.addSubview(contentColumn)
 
 
-        [topBar, search, name, timeTitle, dateTitle, actions, panel, panelHeader, noMeeting, scheduledMeetingCard, openRecordings].forEach {
+        [topBar, search, name, timeTitle, dateTitle, actions, panel, panelHeader, meetingsStatus, noMeeting, meetingsScrollView, openRecordings].forEach {
             $0.translatesAutoresizingMaskIntoConstraints = false
             $0.translatesAutoresizingMaskIntoConstraints = false
             contentColumn.addSubview($0)
             contentColumn.addSubview($0)
         }
         }
-        [meetingTitle, meetingDetails, meetingHost].forEach {
-            $0.translatesAutoresizingMaskIntoConstraints = false
-            scheduledMeetingCard.addSubview($0)
-        }
+        meetingsDocument.translatesAutoresizingMaskIntoConstraints = false
+        meetingsStack.translatesAutoresizingMaskIntoConstraints = false
+        meetingsScrollView.documentView = meetingsDocument
+        meetingsDocument.addSubview(meetingsStack)
 
 
         NSLayoutConstraint.activate([
         NSLayoutConstraint.activate([
             contentColumn.topAnchor.constraint(equalTo: content.topAnchor),
             contentColumn.topAnchor.constraint(equalTo: content.topAnchor),
@@ -443,23 +625,21 @@ class ViewController: NSViewController {
 
 
             panelHeader.topAnchor.constraint(equalTo: panel.topAnchor, constant: 15),
             panelHeader.topAnchor.constraint(equalTo: panel.topAnchor, constant: 15),
             panelHeader.centerXAnchor.constraint(equalTo: panel.centerXAnchor),
             panelHeader.centerXAnchor.constraint(equalTo: panel.centerXAnchor),
+            meetingsStatus.centerYAnchor.constraint(equalTo: panelHeader.centerYAnchor),
+            meetingsStatus.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 18),
             noMeeting.centerXAnchor.constraint(equalTo: panel.centerXAnchor),
             noMeeting.centerXAnchor.constraint(equalTo: panel.centerXAnchor),
             noMeeting.centerYAnchor.constraint(equalTo: panel.centerYAnchor),
             noMeeting.centerYAnchor.constraint(equalTo: panel.centerYAnchor),
 
 
-            scheduledMeetingCard.topAnchor.constraint(equalTo: panel.topAnchor, constant: 58),
-            scheduledMeetingCard.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 14),
-            scheduledMeetingCard.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -14),
-            scheduledMeetingCard.bottomAnchor.constraint(equalTo: openRecordings.topAnchor, constant: -14),
-
-            meetingTitle.topAnchor.constraint(equalTo: scheduledMeetingCard.topAnchor, constant: 16),
-            meetingTitle.leadingAnchor.constraint(equalTo: scheduledMeetingCard.leadingAnchor, constant: 16),
-            meetingTitle.trailingAnchor.constraint(equalTo: scheduledMeetingCard.trailingAnchor, constant: -16),
+            meetingsScrollView.topAnchor.constraint(equalTo: panel.topAnchor, constant: 58),
+            meetingsScrollView.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 14),
+            meetingsScrollView.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -14),
+            meetingsScrollView.bottomAnchor.constraint(equalTo: openRecordings.topAnchor, constant: -14),
 
 
-            meetingDetails.topAnchor.constraint(equalTo: meetingTitle.bottomAnchor, constant: 8),
-            meetingDetails.leadingAnchor.constraint(equalTo: meetingTitle.leadingAnchor),
-
-            meetingHost.topAnchor.constraint(equalTo: meetingDetails.bottomAnchor, constant: 8),
-            meetingHost.leadingAnchor.constraint(equalTo: meetingTitle.leadingAnchor),
+            meetingsDocument.widthAnchor.constraint(equalTo: meetingsScrollView.contentView.widthAnchor),
+            meetingsStack.topAnchor.constraint(equalTo: meetingsDocument.topAnchor),
+            meetingsStack.leadingAnchor.constraint(equalTo: meetingsDocument.leadingAnchor),
+            meetingsStack.trailingAnchor.constraint(equalTo: meetingsDocument.trailingAnchor),
+            meetingsStack.bottomAnchor.constraint(equalTo: meetingsDocument.bottomAnchor),
 
 
             openRecordings.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
             openRecordings.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
             openRecordings.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
             openRecordings.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
@@ -469,12 +649,9 @@ class ViewController: NSViewController {
 
 
         timeLabel = timeTitle
         timeLabel = timeTitle
         dateLabel = dateTitle
         dateLabel = dateTitle
-        meetingCard = scheduledMeetingCard
-        meetingTitleLabel = meetingTitle
-        meetingDetailLabel = meetingDetails
-        meetingHostLabel = meetingHost
+        meetingsListStack = meetingsStack
+        meetingsStatusLabel = meetingsStatus
         emptyMeetingLabel = noMeeting
         emptyMeetingLabel = noMeeting
-        scheduledMeetingCard.isHidden = true
         updateClock()
         updateClock()
         return root
         return root
     }
     }
@@ -577,6 +754,49 @@ class ViewController: NSViewController {
         return root
         return root
     }
     }
 
 
+    private func makeMeetingRowCard(_ meeting: ScheduledMeeting) -> NSView {
+        let card = NSView()
+        card.wantsLayer = true
+        card.layer?.backgroundColor = NSColor(calibratedRed: 35 / 255, green: 40 / 255, blue: 56 / 255, alpha: 1).cgColor
+        card.layer?.cornerRadius = 14
+        card.translatesAutoresizingMaskIntoConstraints = false
+        card.heightAnchor.constraint(equalToConstant: 110).isActive = true
+
+        let dateFormatter = DateFormatter()
+        dateFormatter.dateFormat = "EEE, MMM d"
+        let timeFormatter = DateFormatter()
+        timeFormatter.dateFormat = "h:mm a"
+        let startText = timeFormatter.string(from: meeting.start)
+        let endText = meeting.end.map { timeFormatter.string(from: $0) } ?? ""
+        let range = endText.isEmpty ? startText : "\(startText) - \(endText)"
+
+        let title = makeLabel(meeting.title, size: 17, color: primaryText, weight: .semibold, centered: false)
+        let detail = makeLabel("\(dateFormatter.string(from: meeting.start))\n\(range)", size: 14, color: secondaryText, weight: .regular, centered: false)
+        detail.maximumNumberOfLines = 2
+        let host = makeLabel("Host: \(meeting.host) • \(meeting.source)", size: 13, color: secondaryText, weight: .regular, centered: false)
+
+        [title, detail, host].forEach {
+            $0.translatesAutoresizingMaskIntoConstraints = false
+            card.addSubview($0)
+        }
+
+        NSLayoutConstraint.activate([
+            title.topAnchor.constraint(equalTo: card.topAnchor, constant: 12),
+            title.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
+            title.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
+
+            detail.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 6),
+            detail.leadingAnchor.constraint(equalTo: title.leadingAnchor),
+            detail.trailingAnchor.constraint(equalTo: title.trailingAnchor),
+
+            host.topAnchor.constraint(equalTo: detail.bottomAnchor, constant: 6),
+            host.leadingAnchor.constraint(equalTo: title.leadingAnchor),
+            host.trailingAnchor.constraint(equalTo: title.trailingAnchor)
+        ])
+
+        return card
+    }
+
     private func makeLabel(_ text: String, size: CGFloat, color: NSColor, weight: NSFont.Weight, centered: Bool) -> NSTextField {
     private func makeLabel(_ text: String, size: CGFloat, color: NSColor, weight: NSFont.Weight, centered: Bool) -> NSTextField {
         let label = NSTextField(labelWithString: text)
         let label = NSTextField(labelWithString: text)
         label.font = .systemFont(ofSize: size, weight: weight)
         label.font = .systemFont(ofSize: size, weight: weight)
@@ -628,6 +848,244 @@ struct GoogleUserProfile: Codable, Equatable {
     var picture: String?
     var picture: String?
 }
 }
 
 
+struct ZoomOAuthTokens: Codable, Equatable {
+    var accessToken: String
+    var refreshToken: String?
+    var expiresAt: Date
+    var scope: String?
+    var tokenType: String?
+}
+
+enum ZoomOAuthError: Error {
+    case missingClientId
+    case missingClientSecret
+    case invalidCallbackURL
+    case missingAuthorizationCode
+    case tokenExchangeFailed(String)
+    case missingRequiredScope(String)
+    case unableToOpenBrowser
+    case authenticationTimedOut
+}
+
+final class ZoomOAuthTokenStore {
+    private let defaultsKey: String
+    private let defaults: UserDefaults
+
+    init(service: String = Bundle.main.bundleIdentifier ?? "zoom_app",
+         account: String = "zoomOAuthTokens",
+         defaults: UserDefaults = .standard) {
+        self.defaultsKey = "\(service).\(account)"
+        self.defaults = defaults
+    }
+
+    func readTokens() throws -> ZoomOAuthTokens? {
+        guard let data = defaults.data(forKey: defaultsKey) else { return nil }
+        return try JSONDecoder().decode(ZoomOAuthTokens.self, from: data)
+    }
+
+    func writeTokens(_ tokens: ZoomOAuthTokens) throws {
+        let data = try JSONEncoder().encode(tokens)
+        defaults.set(data, forKey: defaultsKey)
+    }
+
+    func clearTokens() {
+        defaults.removeObject(forKey: defaultsKey)
+    }
+}
+
+final class ZoomOAuthService: NSObject {
+    static let shared = ZoomOAuthService()
+
+    private let tokenStore = ZoomOAuthTokenStore()
+    private let clientIdDefaultsKey = "zoom.oauth.clientId"
+    private let clientSecretDefaultsKey = "zoom.oauth.clientSecret"
+    private let infoPlistClientIdKey = "ZoomOAuthClientId"
+    private let envClientSecretKey = "ZOOM_OAUTH_CLIENT_SECRET"
+    // Optional: put OAuth app credentials here for local-only testing (do not ship secrets in release builds).
+    /// Fallback if Info.plist `ZoomOAuthClientId` is missing (e.g. mis-quoted build setting).
+    private let bundledClientId = "isvIAKPhSPOhBxFUkiY2A"
+    /// Prefer `ZOOM_OAUTH_CLIENT_SECRET` env or UserDefaults when distributing; rotate if this value is ever leaked.
+    private let bundledClientSecret = "jPfbdvt14CKH48vKEg3NjDpTIgCd2rDq"
+
+    func setClientCredentials(clientId: String, clientSecret: String) {
+        UserDefaults.standard.set(clientId, forKey: clientIdDefaultsKey)
+        UserDefaults.standard.set(clientSecret, forKey: clientSecretDefaultsKey)
+    }
+
+    func configuredClientId() -> String? {
+        if let plist = Bundle.main.object(forInfoDictionaryKey: infoPlistClientIdKey) as? String {
+            let trimmed = plist.trimmingCharacters(in: .whitespacesAndNewlines)
+            if trimmed.isEmpty == false { return trimmed }
+        }
+        let value = UserDefaults.standard.string(forKey: clientIdDefaultsKey)?
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+        if let value, value.isEmpty == false { return value }
+        return bundledClientId.isEmpty ? nil : bundledClientId
+    }
+
+    func configuredClientSecret() -> String? {
+        if let env = ProcessInfo.processInfo.environment[envClientSecretKey] {
+            let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines)
+            if trimmed.isEmpty == false { return trimmed }
+        }
+        let value = UserDefaults.standard.string(forKey: clientSecretDefaultsKey)?
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+        if let value, value.isEmpty == false { return value }
+        return bundledClientSecret.isEmpty ? nil : bundledClientSecret
+    }
+
+    func clearSavedTokens() {
+        tokenStore.clearTokens()
+    }
+
+    func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
+        if let tokens = try tokenStore.readTokens(),
+           tokens.expiresAt.timeIntervalSinceNow > 60,
+           tokenHasRequiredScope(tokens.scope) {
+            return tokens.accessToken
+        } else if var tokens = try tokenStore.readTokens(),
+                  let refreshed = try await refreshTokens(tokens) {
+            tokens = refreshed
+            try tokenStore.writeTokens(tokens)
+            return tokens.accessToken
+        }
+
+        let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
+        try tokenStore.writeTokens(tokens)
+        return tokens.accessToken
+    }
+
+    private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> ZoomOAuthTokens {
+        _ = presentingWindow
+        guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
+        guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
+
+        let loopback = try await OAuthLoopbackServer.start()
+        defer { loopback.stop() }
+        let redirectURI = loopback.redirectURI
+        let state = UUID().uuidString
+
+        var components = URLComponents(string: "https://zoom.us/oauth/authorize")!
+        // Omit `scope` so Zoom uses the OAuth app’s enabled scopes from the Marketplace (avoids mismatch errors).
+        components.queryItems = [
+            URLQueryItem(name: "response_type", value: "code"),
+            URLQueryItem(name: "client_id", value: clientId),
+            URLQueryItem(name: "redirect_uri", value: redirectURI),
+            URLQueryItem(name: "state", value: state)
+        ]
+        guard let authURL = components.url else { throw ZoomOAuthError.invalidCallbackURL }
+        let opened = await MainActor.run { NSWorkspace.shared.open(authURL) }
+        guard opened else { throw ZoomOAuthError.unableToOpenBrowser }
+
+        let callbackURL = try await loopback.waitForCallback()
+        let queryItems = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
+        guard queryItems?.first(where: { $0.name == "state" })?.value == state else { throw ZoomOAuthError.invalidCallbackURL }
+        guard let code = queryItems?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
+            throw ZoomOAuthError.missingAuthorizationCode
+        }
+
+        return try await exchangeCodeForTokens(code: code, redirectURI: redirectURI, clientId: clientId, clientSecret: clientSecret)
+    }
+
+    private func exchangeCodeForTokens(code: String, redirectURI: String, clientId: String, clientSecret: String) async throws -> ZoomOAuthTokens {
+        var request = URLRequest(url: URL(string: "https://zoom.us/oauth/token")!)
+        request.httpMethod = "POST"
+        request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
+        request.setValue("Basic \(Self.basicAuth(clientId: clientId, clientSecret: clientSecret))", forHTTPHeaderField: "Authorization")
+        request.httpBody = Self.formURLEncoded([
+            "grant_type": "authorization_code",
+            "code": code,
+            "redirect_uri": redirectURI
+        ])
+
+        let (data, response) = try await URLSession.shared.data(for: request)
+        guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
+            throw ZoomOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
+        }
+
+        struct TokenResponse: Decodable {
+            let access_token: String
+            let refresh_token: String?
+            let expires_in: Double
+            let scope: String?
+            let token_type: String?
+        }
+
+        let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
+        return ZoomOAuthTokens(
+            accessToken: decoded.access_token,
+            refreshToken: decoded.refresh_token,
+            expiresAt: Date().addingTimeInterval(decoded.expires_in),
+            scope: decoded.scope,
+            tokenType: decoded.token_type
+        )
+    }
+
+    private func refreshTokens(_ tokens: ZoomOAuthTokens) async throws -> ZoomOAuthTokens? {
+        guard let refreshToken = tokens.refreshToken else { return nil }
+        guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
+        guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
+
+        var request = URLRequest(url: URL(string: "https://zoom.us/oauth/token")!)
+        request.httpMethod = "POST"
+        request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
+        request.setValue("Basic \(Self.basicAuth(clientId: clientId, clientSecret: clientSecret))", forHTTPHeaderField: "Authorization")
+        request.httpBody = Self.formURLEncoded([
+            "grant_type": "refresh_token",
+            "refresh_token": refreshToken
+        ])
+
+        let (data, response) = try await URLSession.shared.data(for: request)
+        guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
+            return nil
+        }
+
+        struct RefreshResponse: Decodable {
+            let access_token: String
+            let refresh_token: String?
+            let expires_in: Double
+            let scope: String?
+            let token_type: String?
+        }
+
+        let decoded = try JSONDecoder().decode(RefreshResponse.self, from: data)
+        return ZoomOAuthTokens(
+            accessToken: decoded.access_token,
+            refreshToken: decoded.refresh_token ?? refreshToken,
+            expiresAt: Date().addingTimeInterval(decoded.expires_in),
+            scope: decoded.scope ?? tokens.scope,
+            tokenType: decoded.token_type ?? tokens.tokenType
+        )
+    }
+
+    private func tokenHasRequiredScope(_ scopeValue: String?) -> Bool {
+        guard let scopeValue, scopeValue.isEmpty == false else { return false }
+        let parts = scopeValue.split { $0 == " " || $0 == "," }.map(String.init)
+        return parts.contains { part in
+            part == "meeting:read"
+                || part == "meeting:read:admin"
+                || part.contains("meeting:read")
+                || part.contains("list_meetings")
+                || part.contains("list_user_meetings")
+        }
+    }
+
+    private static func basicAuth(clientId: String, clientSecret: String) -> String {
+        let joined = "\(clientId):\(clientSecret)"
+        return Data(joined.utf8).base64EncodedString()
+    }
+
+    private static func formURLEncoded(_ params: [String: String]) -> Data {
+        let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
+        let pairs = params.map { key, value in
+            let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
+            let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
+            return "\(k)=\(v)"
+        }.joined(separator: "&")
+        return Data(pairs.utf8)
+    }
+}
+
 enum GoogleOAuthError: Error {
 enum GoogleOAuthError: Error {
     case missingClientId
     case missingClientId
     case missingClientSecret
     case missingClientSecret
@@ -797,6 +1255,9 @@ final class KeychainTokenStore {
 }
 }
 
 
 private final class OAuthLoopbackServer {
 private final class OAuthLoopbackServer {
+    /// Fixed port so Zoom/Google OAuth redirect URLs can be registered exactly (Zoom allow list does not support wildcards for ports).
+    private static let loopbackOAuthPort: UInt16 = 8742
+
     private let queue = DispatchQueue(label: "google.oauth.loopback.server")
     private let queue = DispatchQueue(label: "google.oauth.loopback.server")
     private let listener: NWListener
     private let listener: NWListener
     private var readyContinuation: CheckedContinuation<Void, Error>?
     private var readyContinuation: CheckedContinuation<Void, Error>?
@@ -808,7 +1269,10 @@ private final class OAuthLoopbackServer {
     }
     }
 
 
     static func start() async throws -> OAuthLoopbackServer {
     static func start() async throws -> OAuthLoopbackServer {
-        let listener = try NWListener(using: .tcp, on: .any)
+        guard let port = NWEndpoint.Port(rawValue: loopbackOAuthPort) else {
+            throw GoogleOAuthError.invalidCallbackURL
+        }
+        let listener = try NWListener(using: .tcp, on: port)
         let server = OAuthLoopbackServer(listener: listener)
         let server = OAuthLoopbackServer(listener: listener)
         try await server.startListening()
         try await server.startListening()
         return server
         return server
@@ -954,3 +1418,26 @@ extension GoogleOAuthError: LocalizedError {
     }
     }
 }
 }
 
 
+extension ZoomOAuthError: LocalizedError {
+    var errorDescription: String? {
+        switch self {
+        case .missingClientId:
+            return "Zoom OAuth Client ID is not set (Info.plist ZoomOAuthClientId, UserDefaults, or the setup prompt)."
+        case .missingClientSecret:
+            return "Zoom OAuth Client Secret is not set (environment ZOOM_OAUTH_CLIENT_SECRET, UserDefaults, or the setup prompt)."
+        case .invalidCallbackURL:
+            return "The OAuth redirect URL was invalid. In your Zoom app OAuth allow list, add exactly http://127.0.0.1:8742/oauth2redirect (must match OAuthLoopbackServer.loopbackOAuthPort in this target)."
+        case .missingAuthorizationCode:
+            return "Zoom did not return an authorization code."
+        case .tokenExchangeFailed(let details):
+            return details
+        case .missingRequiredScope(let details):
+            return "The Zoom access token is missing required scopes. \(details)"
+        case .unableToOpenBrowser:
+            return "Could not open the system browser for Zoom sign-in."
+        case .authenticationTimedOut:
+            return "Zoom sign-in timed out waiting for the browser redirect."
+        }
+    }
+}
+