diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarOverlays.swift b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarOverlays.swift index 5972a530e6..4cf638c3e7 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarOverlays.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarOverlays.swift @@ -46,7 +46,8 @@ extension NativeCalendarRootModel { let items = filteredPaletteSections().flatMap(\.items) guard !items.isEmpty else { return } let index = items.firstIndex { $0.id == commandPaletteStore.selectedCommandId } ?? 0 - let next = (index + delta % items.count + items.count) % items.count + let count = items.count + let next = ((index + delta) % count + count) % count commandPaletteStore.select(commandId: items[next].id) } diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+EventJump.swift b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+EventJump.swift index d2216f8bc1..195b794ffe 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+EventJump.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+EventJump.swift @@ -40,13 +40,13 @@ extension NativeCalendarRootModel { return } - guard let prefix = DayJumpPrefixes.dayPrefix(forShiftedLetter: letter), prefix != "s" else { return } + guard let prefix = DayJumpPrefixes.dayPrefix(forShiftedLetter: letter) else { return } selectJumpDay(prefix: prefix) } private func selectJumpDay(prefix: String) { let calendar = EffectiveTimeZone.calendar - for dayKey in visibleDateKeysForJump() { + for dayKey in visibleDateKeys() { guard let date = CompassDateParsing.calendarDateInEffectiveTimeZone(dayKey) else { continue } let weekday = calendar.component(.weekday, from: date) - 1 guard DayJumpPrefixes.prefix(forWeekday: weekday) == prefix else { continue } @@ -83,13 +83,4 @@ extension NativeCalendarRootModel { } } - private func visibleDateKeysForJump() -> [String] { - let calendar = EffectiveTimeZone.calendar - return (0 ..< viewStore.visibleDayCount).compactMap { offset in - guard let day = calendar.date(byAdding: .day, value: offset, to: startOfView) else { - return nil - } - return CompassDateParsing.formatCalendarDay(day) - } - } } diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift index ddbb68a21c..21460f7e9b 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift @@ -948,7 +948,7 @@ public final class NativeCalendarRootModel { ) ?? startOfView } - private func visibleDateKeys() -> [String] { + func visibleDateKeys() -> [String] { let calendar = EffectiveTimeZone.calendar return (0 ..< viewStore.visibleDayCount).compactMap { offset in guard let day = calendar.date(byAdding: .day, value: offset, to: startOfView) else { diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/UITestLaunchPolicy.swift b/apps/calendar-macos/CompassData/Sources/CompassData/UITestLaunchPolicy.swift index a62ca8c52c..36a64d4b50 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/UITestLaunchPolicy.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/UITestLaunchPolicy.swift @@ -29,22 +29,12 @@ public enum UITestLaunchPolicy { /// When set, focuses this grid event once the demo fixture layout is ready (no pointer hint). public static var initialGridFocusEventId: String? { - let args = ProcessInfo.processInfo.arguments - guard let index = args.firstIndex(of: "-COMPASS_UI_TEST_INITIAL_GRID_FOCUS_EVENT"), - index + 1 < args.count - else { return nil } - let value = args[index + 1] - return value.isEmpty ? nil : value + stringValue(forFlag: "-COMPASS_UI_TEST_INITIAL_GRID_FOCUS_EVENT") } /// When set, a grid tap that would show the scroll hint focuses this event id instead. public static var gridClickFocusEventId: String? { - let args = ProcessInfo.processInfo.arguments - guard let index = args.firstIndex(of: "-COMPASS_UI_TEST_GRID_CLICK_FOCUS_EVENT"), - index + 1 < args.count - else { return nil } - let value = args[index + 1] - return value.isEmpty ? nil : value + stringValue(forFlag: "-COMPASS_UI_TEST_GRID_CLICK_FOCUS_EVENT") } /// Opens the native edit form for the initially focused grid event once layout is ready. @@ -98,11 +88,17 @@ public enum UITestLaunchPolicy { /// Seeds the open event form title (XCUITest typing is flaky for recurring edits in CI). public static var presetEventFormTitle: String? { - let args = ProcessInfo.processInfo.arguments - guard let index = args.firstIndex(of: "-COMPASS_UI_TEST_EVENT_FORM_TITLE"), - index + 1 < args.count - else { return nil } - let value = args[index + 1] + stringValue(forFlag: "-COMPASS_UI_TEST_EVENT_FORM_TITLE") + } + + private static func stringValue( + forFlag flag: String, + in arguments: [String] = ProcessInfo.processInfo.arguments + ) -> String? { + guard let index = arguments.firstIndex(of: flag), index + 1 < arguments.count else { + return nil + } + let value = arguments[index + 1] return value.isEmpty ? nil : value } diff --git a/apps/calendar-macos/CompassKit/Sources/CompassKit/CommandPalette/CommandPaletteSearchLogic.swift b/apps/calendar-macos/CompassKit/Sources/CompassKit/CommandPalette/CommandPaletteSearchLogic.swift index 6f135787ee..5e444a6dd1 100644 --- a/apps/calendar-macos/CompassKit/Sources/CompassKit/CommandPalette/CommandPaletteSearchLogic.swift +++ b/apps/calendar-macos/CompassKit/Sources/CompassKit/CommandPalette/CommandPaletteSearchLogic.swift @@ -4,7 +4,7 @@ import Foundation public enum CommandPaletteSearchLogic { private static let minSubsequenceTokenLength = 3 - public struct ScoredItem: Hashable, Sendable { + private struct ScoredItem: Hashable, Sendable { public let item: CommandPaletteSearchItem public let score: Int } @@ -130,7 +130,7 @@ public enum CommandPaletteSearchLogic { private static func hasWordStartingWith(haystack: String, token: String) -> Bool { haystack.split(whereSeparator: { $0.isWhitespace || $0 == "-" }) - .contains { $0.lowercased().hasPrefix(token) } + .contains { $0.hasPrefix(token) } } private static func isSubsequence(haystack: String, token: String) -> Bool { diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormCard.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormCard.swift index 38e9cc4700..d086173958 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormCard.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormCard.swift @@ -4,6 +4,7 @@ import SwiftUI /// Web `FormCard`: grouped fields on `surface-overlay` with default radius. struct EventFormCard: View { @Environment(\.nativeWebTheme) private var theme + var highlighted = false @ViewBuilder var content: () -> Content var body: some View { @@ -12,5 +13,11 @@ struct EventFormCard: View { .frame(maxWidth: .infinity, alignment: .leading) .background(theme.surfaceOverlayColor) .clipShape(RoundedRectangle(cornerRadius: NativeWebLayoutTokens.radiusDefault)) + .overlay { + if highlighted { + RoundedRectangle(cornerRadius: NativeWebLayoutTokens.radiusDefault) + .stroke(theme.accentColor, lineWidth: 1) + } + } } } diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormCompactDateField.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormCompactDateField.swift index 872e3ce351..3127949fe1 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormCompactDateField.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormCompactDateField.swift @@ -62,7 +62,7 @@ struct EventFormCompactDateField: View { private func commitTypedDate() { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { - text = EventFormDateFormat.formatLabel(for: date) + revertTextToBoundDate() return } if let parsed = EventFormDateFormat.parseTypedLabel(trimmed, now: date) { @@ -70,6 +70,10 @@ struct EventFormCompactDateField: View { text = EventFormDateFormat.formatLabel(for: parsed) return } + revertTextToBoundDate() + } + + private func revertTextToBoundDate() { text = EventFormDateFormat.formatLabel(for: date) } } diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormConferenceSection.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormConferenceSection.swift index a29fcb9691..efd062c228 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormConferenceSection.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormConferenceSection.swift @@ -6,7 +6,6 @@ struct EventFormConferenceSection: View { @Environment(\.nativeWebTheme) private var theme @Bindable var model: NativeCalendarRootModel let draft: GridEventDraft - let focusField: EventFormField var body: some View { if model.showCreateConferenceToggle(for: draft), diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormScheduleSection.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormScheduleSection.swift index 07d292a97b..af00a4b0bd 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormScheduleSection.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormScheduleSection.swift @@ -6,7 +6,6 @@ struct EventFormScheduleSection: View { @Environment(\.nativeWebTheme) private var theme @Bindable var model: NativeCalendarRootModel let draft: GridEventDraft - let focusField: EventFormField private var scheduleLocked: Bool { model.isScheduleLocked(for: draft) @@ -23,57 +22,22 @@ struct EventFormScheduleSection: View { .accessibilityIdentifier("compass-event-form-allday") if draft.schedule.kind == .timed { - HStack(spacing: 8) { - EventFormCompactDateField( - date: Binding( - get: { draft.schedule.start }, - set: { updateStart(mergeCalendarDay(into: draft.schedule.start, day: $0)) } - ), - accessibilityIdentifier: "compass-event-form-start-date" - ) - .disabled(scheduleLocked) - .pageJumpChipAnchor(id: "form-start") - - DatePicker( - "Start time", - selection: Binding( - get: { draft.schedule.start }, - set: { updateStart($0) } - ), - displayedComponents: [.hourAndMinute] - ) - .labelsHidden() - .controlSize(.small) - .frame(minHeight: 38) - .disabled(scheduleLocked) - .accessibilityIdentifier("compass-event-form-start") - } - - HStack(spacing: 8) { - EventFormCompactDateField( - date: Binding( - get: { draft.schedule.end }, - set: { updateEnd(mergeCalendarDay(into: draft.schedule.end, day: $0)) } - ), - accessibilityIdentifier: "compass-event-form-end-date" - ) - .disabled(scheduleLocked) - .pageJumpChipAnchor(id: "form-end") - - DatePicker( - "End time", - selection: Binding( - get: { draft.schedule.end }, - set: { updateEnd($0) } - ), - displayedComponents: [.hourAndMinute] - ) - .labelsHidden() - .controlSize(.small) - .frame(minHeight: 38) - .disabled(scheduleLocked) - .accessibilityIdentifier("compass-event-form-end") - } + timedRow( + date: draft.schedule.start, + dateAccessibilityIdentifier: "compass-event-form-start-date", + timeLabel: "Start time", + timeAccessibilityIdentifier: "compass-event-form-start", + pageJumpId: "form-start", + update: updateStart + ) + timedRow( + date: draft.schedule.end, + dateAccessibilityIdentifier: "compass-event-form-end-date", + timeLabel: "End time", + timeAccessibilityIdentifier: "compass-event-form-end", + pageJumpId: "form-end", + update: updateEnd + ) } else { EventFormCompactDateField( date: Binding( @@ -111,6 +75,42 @@ struct EventFormScheduleSection: View { } } + @ViewBuilder + private func timedRow( + date: Date, + dateAccessibilityIdentifier: String, + timeLabel: String, + timeAccessibilityIdentifier: String, + pageJumpId: String, + update: @escaping (Date) -> Void + ) -> some View { + HStack(spacing: 8) { + EventFormCompactDateField( + date: Binding( + get: { date }, + set: { update(mergeCalendarDay(into: date, day: $0)) } + ), + accessibilityIdentifier: dateAccessibilityIdentifier + ) + .disabled(scheduleLocked) + .pageJumpChipAnchor(id: pageJumpId) + + DatePicker( + timeLabel, + selection: Binding( + get: { date }, + set: { update($0) } + ), + displayedComponents: [.hourAndMinute] + ) + .labelsHidden() + .controlSize(.small) + .frame(minHeight: 38) + .disabled(scheduleLocked) + .accessibilityIdentifier(timeAccessibilityIdentifier) + } + } + private func mergeCalendarDay(into scheduleDate: Date, day: Date) -> Date { var calendar = EffectiveTimeZone.calendar let time = calendar.dateComponents([.hour, .minute, .second], from: scheduleDate) diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormView.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormView.swift index f61a6fb34d..f8b6df48a8 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormView.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormView.swift @@ -53,8 +53,7 @@ public struct EventFormView: View { VStack(alignment: .leading, spacing: 10) { EventFormScheduleSection( model: model, - draft: draft, - focusField: model.eventFormFocusedField + draft: draft ) EventFormRecurrenceSection( model: model, @@ -82,7 +81,7 @@ public struct EventFormView: View { } } .id(EventFormField.calendar) - EventFormCard { + EventFormCard(highlighted: isLogisticsFieldFocused(model.eventFormFocusedField)) { VStack(alignment: .leading, spacing: 10) { EventFormLocationField( model: model, @@ -92,8 +91,7 @@ public struct EventFormView: View { .disabled(isReadOnly) EventFormConferenceSection( model: model, - draft: draft, - focusField: model.eventFormFocusedField + draft: draft ) EventFormAttendeesSection( model: model, @@ -103,14 +101,8 @@ public struct EventFormView: View { .id(EventFormField.attendees) } } - .overlay { - if isLogisticsFieldFocused(model.eventFormFocusedField) { - RoundedRectangle(cornerRadius: NativeWebLayoutTokens.radiusDefault) - .stroke(theme.accentColor, lineWidth: 1) - } - } .id(EventFormField.location) - EventFormCard { + EventFormCard(highlighted: model.eventFormFocusedField == .description) { DescriptionEditorView( html: Binding( get: { draft.description }, @@ -123,12 +115,6 @@ public struct EventFormView: View { ) .frame(minHeight: 72, maxHeight: 140) } - .overlay { - if model.eventFormFocusedField == .description { - RoundedRectangle(cornerRadius: NativeWebLayoutTokens.radiusDefault) - .stroke(theme.accentColor, lineWidth: 1) - } - } .pageJumpChipAnchor(id: "form-description") .id(EventFormField.description) diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/NativeWebFocusRing.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/NativeWebFocusRing.swift deleted file mode 100644 index dece0dbe07..0000000000 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/NativeWebFocusRing.swift +++ /dev/null @@ -1,32 +0,0 @@ -import SwiftUI - -extension View { - /// Web `c-focus-ring`: 1px accent stroke with a 1px surface-panel offset. - func nativeWebFocusRing(_ isFocused: Bool, cornerRadius: CGFloat = 4) -> some View { - modifier(NativeWebFocusRing(isFocused: isFocused, cornerRadius: cornerRadius)) - } -} - -private struct NativeWebFocusRing: ViewModifier { - @Environment(\.nativeWebTheme) private var theme - let isFocused: Bool - let cornerRadius: CGFloat - - func body(content: Content) -> some View { - content - .padding(isFocused ? 1 : 0) - .background { - if isFocused { - RoundedRectangle(cornerRadius: cornerRadius + 1) - .fill(theme.surfacePanelColor) - } - } - .overlay { - if isFocused { - RoundedRectangle(cornerRadius: cornerRadius) - .stroke(theme.accentColor, lineWidth: 1) - .padding(1) - } - } - } -} diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/TimeGrid/TimeGridView.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/TimeGrid/TimeGridView.swift index b52a8c04aa..95fbee2e68 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/TimeGrid/TimeGridView.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/TimeGrid/TimeGridView.swift @@ -184,13 +184,10 @@ public final class TimeGridView: NSView { private func pulseHeaderLabel(_ header: NSTextField, accent: NSColor) { header.wantsLayer = true - let shimmer = CABasicAnimation(keyPath: "opacity") - shimmer.fromValue = 1 - shimmer.toValue = 0.55 - shimmer.duration = 0.7 - shimmer.autoreverses = true - shimmer.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) - header.layer?.add(shimmer, forKey: "nowCueLabelShimmer") + header.layer?.add( + nowCuePulseAnimation(keyPath: "opacity", from: 1, to: 0.55), + forKey: "nowCueLabelShimmer" + ) if let attributed = header.attributedStringValue.mutableCopy() as? NSMutableAttributedString { attributed.addAttribute(.foregroundColor, value: accent, range: NSRange(location: 0, length: attributed.length)) header.attributedStringValue = attributed diff --git a/packages/core/src/analytics/oauth-callback.analytics.ts b/packages/core/src/analytics/oauth-callback.analytics.ts index ad51dda90d..edb02ce570 100644 --- a/packages/core/src/analytics/oauth-callback.analytics.ts +++ b/packages/core/src/analytics/oauth-callback.analytics.ts @@ -60,6 +60,7 @@ export function oauthCallbackFailureReason(input: { ? input.oauthErrorDescription : undefined, ); + const mergedProviderError = providerError ?? providerDescription; if (input.outcome === "declined") { const reason = @@ -68,10 +69,7 @@ export function oauthCallbackFailureReason(input: { code: typeof input.code === "string" ? input.code : null, providerError: providerErrorRaw ?? null, }) ?? "other"; - return { - reason, - ...(providerError ? { provider_error: providerError } : {}), - }; + return withProviderError(reason, providerError); } if (input.outcome === "stateMismatch") { @@ -83,16 +81,10 @@ export function oauthCallbackFailureReason(input: { const hasCode = typeof input.code === "string" && input.code.length > 0; if (!hasState || input.stateVerifyReason === "malformed") { - return { - reason: "invalid_state", - ...(providerError ? { provider_error: providerError } : {}), - }; + return withProviderError("invalid_state", providerError); } if (!hasCode) { - return { - reason: "missing_code", - ...(providerError ? { provider_error: providerError } : {}), - }; + return withProviderError("missing_code", providerError); } if ( input.stateVerifyReason === "invalidSignature" || @@ -106,21 +98,19 @@ export function oauthCallbackFailureReason(input: { provider_error: providerErrorFromExchange(input.exchangeError), }; } - return { - reason: "other", - ...((providerError ?? providerDescription) - ? { - provider_error: providerError ?? providerDescription, - } - : {}), - }; + return withProviderError("other", mergedProviderError); } + return withProviderError("other", mergedProviderError); +} + +function withProviderError( + reason: OAuthFailedReason, + providerError?: string, +): { reason: OAuthFailedReason; provider_error?: string } { return { - reason: "other", - ...((providerError ?? providerDescription) - ? { provider_error: providerError ?? providerDescription } - : {}), + reason, + ...(providerError ? { provider_error: providerError } : {}), }; } diff --git a/packages/sync/src/oauth/oauth-state.ts b/packages/sync/src/oauth/oauth-state.ts index 76ee3b71b5..fff2027951 100644 --- a/packages/sync/src/oauth/oauth-state.ts +++ b/packages/sync/src/oauth/oauth-state.ts @@ -34,7 +34,7 @@ export interface OAuthStatePayload { // Default lifetime of a state token: long enough for a human to complete // consent, short enough to bound replay of a leaked callback URL. -export const DEFAULT_OAUTH_STATE_TTL_MS = 10 * 60 * 1000; +const DEFAULT_OAUTH_STATE_TTL_MS = 10 * 60 * 1000; // Derive a purpose-specific signing key from the service root secret, so the // OAuth state and internal-auth request signatures never share a key. Domain @@ -78,12 +78,11 @@ export function verifyOAuthState( options: VerifyOAuthStateOptions = {}, ): OAuthStateResult { const ttlMs = options.ttlMs ?? DEFAULT_OAUTH_STATE_TTL_MS; - const dot = token.indexOf("."); - if (dot <= 0 || dot === token.length - 1) { + const parts = splitSignedToken(token); + if (!parts) { return { ok: false, reason: "malformed" }; } - const encoded = token.slice(0, dot); - const signature = token.slice(dot + 1); + const { encoded, signature } = parts; // Verify the signature before trusting any byte of the payload. if (!signaturesMatch(sign(secret, encoded), signature)) { @@ -116,9 +115,17 @@ function signaturesMatch(expected: string, provided: string): boolean { /** Decode the payload without verifying the signature (analytics only). */ export function peekOAuthStatePayload(token: string): OAuthStatePayload | null { + const parts = splitSignedToken(token); + if (!parts) return null; + return decodePayload(parts.encoded); +} + +function splitSignedToken( + token: string, +): { encoded: string; signature: string } | null { const dot = token.indexOf("."); if (dot <= 0 || dot === token.length - 1) return null; - return decodePayload(token.slice(0, dot)); + return { encoded: token.slice(0, dot), signature: token.slice(dot + 1) }; } function encodePayload(payload: OAuthStatePayload): string { diff --git a/packages/sync/src/server/connection.routes.ts b/packages/sync/src/server/connection.routes.ts index ee59a1dec2..ade4a8590c 100644 --- a/packages/sync/src/server/connection.routes.ts +++ b/packages/sync/src/server/connection.routes.ts @@ -118,8 +118,6 @@ const CONNECT_CORRELATION_QUERY = "cid"; const CONNECT_INTENT_QUERY = "intent"; const CONNECT_CORRELATION_ID_LENGTH = 8; -type OAuthCallbackOutcome = SyncOAuthCallbackOutcome; - type OAuthConnectIntent = "connect" | "reconnect"; export class ReconnectAccountMismatchError extends Error { @@ -883,7 +881,7 @@ export function registerConnectionRoutes( }; const redirect = ( - status: OAuthCallbackOutcome, + status: SyncOAuthCallbackOutcome, error?: unknown, stateVerifyReason?: OAuthStateFailure, ) => @@ -1105,36 +1103,29 @@ function oauthCallbackErrorClass(error: unknown): string { return "Error"; } -function oauthCallbackDistinctId( +function oauthStateAnalyticsContext( deps: ConnectionApiDeps, state: unknown, -): string | undefined { - if (typeof state !== "string" || state.length === 0) return undefined; +): { + distinctId?: string; + intent?: OAuthConnectIntent; + relayDesktop: boolean; +} { + if (typeof state !== "string" || state.length === 0) { + return { relayDesktop: false }; + } const now = (deps.now ?? Date.now)(); const verified = verifyOAuthState(deps.stateSecret, state, now); - if (verified.ok) return verified.payload.principalId; - return peekOAuthStatePayload(state)?.principalId; -} - -function connectIntentFromState( - deps: ConnectionApiDeps, - state: unknown, - expectedProvider: ProviderKind, -): OAuthConnectIntent | undefined { - if (typeof state !== "string") return undefined; - const now = (deps.now ?? Date.now)(); - const withProvider = verifyOAuthState(deps.stateSecret, state, now, { - expectedProvider, - }); - if (withProvider.ok) { - return withProvider.payload.connectionId !== null ? "reconnect" : "connect"; - } - // Signature-valid state on the wrong provider path still carries intent. - const anyProvider = verifyOAuthState(deps.stateSecret, state, now); - if (anyProvider.ok) { - return anyProvider.payload.connectionId !== null ? "reconnect" : "connect"; - } - return undefined; + const payload = verified.ok ? verified.payload : peekOAuthStatePayload(state); + return { + distinctId: payload?.principalId, + intent: verified.ok + ? verified.payload.connectionId !== null + ? "reconnect" + : "connect" + : undefined, + relayDesktop: verified.ok && verified.payload.relayDesktop === true, + }; } async function captureOAuthCallbackThenRedirect( @@ -1142,7 +1133,7 @@ async function captureOAuthCallbackThenRedirect( res: Response, input: { provider: ProviderKind; - outcome: OAuthCallbackOutcome; + outcome: SyncOAuthCallbackOutcome; state: unknown; code: unknown; oauthError: unknown; @@ -1152,9 +1143,10 @@ async function captureOAuthCallbackThenRedirect( }, ): Promise { const correlationId = newConnectCorrelationId(); - const intent = connectIntentFromState(deps, input.state, input.provider); - const relayDesktop = relayDesktopConnectFromState(deps, input.state); - const distinctId = oauthCallbackDistinctId(deps, input.state); + const { distinctId, intent, relayDesktop } = oauthStateAnalyticsContext( + deps, + input.state, + ); const properties: Record = { provider: input.provider, outcome: input.outcome, @@ -1173,12 +1165,7 @@ async function captureOAuthCallbackThenRedirect( stateVerifyReason: input.stateVerifyReason, exchangeError: input.error, }); - if (failure) { - properties["reason"] = failure.reason; - if (failure.provider_error) { - properties["provider_error"] = failure.provider_error; - } - } + if (failure) Object.assign(properties, failure); if (distinctId) { await captureSafely(deps.posthog ?? null, { event: OAUTH_CALLBACK_EVENT, @@ -1193,16 +1180,6 @@ async function captureOAuthCallbackThenRedirect( }); } -function relayDesktopConnectFromState( - deps: ConnectionApiDeps, - state: unknown, -): boolean { - if (typeof state !== "string") return false; - const now = (deps.now ?? Date.now)(); - const verified = verifyOAuthState(deps.stateSecret, state, now); - return verified.ok && verified.payload.relayDesktop === true; -} - async function linkCredentialConnection( deps: ConnectionApiDeps, input: {