From 445e87a6a6272ed3558440a783ac0db4477922a2 Mon Sep 17 00:00:00 2001 From: Sourav P Bijoy <71513365+Phloraxx@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:15:21 +0530 Subject: [PATCH 01/29] Add Google Messages cookie reauthentication --- internal/gmessages/reauth.go | 225 +++++++++++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 internal/gmessages/reauth.go diff --git a/internal/gmessages/reauth.go b/internal/gmessages/reauth.go new file mode 100644 index 0000000..cd83c04 --- /dev/null +++ b/internal/gmessages/reauth.go @@ -0,0 +1,225 @@ +package gmessages + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "go.mau.fi/mautrix-gmessages/pkg/libgm" + "go.mau.fi/mautrix-gmessages/pkg/libgm/events" +) + +const googleReauthRequiredMessage = "Google account authentication expired; refresh the Google login to reconnect" + +func isGoogleAuthError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, events.ErrInvalidCredentials) { + return true + } + var httpErr events.HTTPError + if !errors.As(err, &httpErr) || httpErr.Resp == nil { + return false + } + return httpErr.Resp.StatusCode == http.StatusUnauthorized || httpErr.Resp.StatusCode == http.StatusForbidden +} + +func (m *Manager) connectionEventsSuppressed() bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.status.State == "reauth_required" || m.status.State == "reauthenticating" +} + +func (m *Manager) googleAccountPairingExists() bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.session != nil && m.session.IsGoogleAccount() && m.session.Browser != nil && m.session.Mobile != nil +} + +func (m *Manager) handleGoogleAuthFailure(err error) bool { + m.mu.RLock() + googleSession := m.session != nil && m.session.IsGoogleAccount() + m.mu.RUnlock() + if !googleSession || !isGoogleAuthError(err) { + return false + } + m.logger.Warn().Err(err).Msg("Google Messages account authentication requires refresh") + m.markGoogleReauthRequired() + return true +} + +func (m *Manager) markGoogleReauthRequired() { + m.mu.Lock() + client := m.client + m.client = nil + m.status.State = "reauth_required" + m.status.Paired = m.session != nil && m.session.Browser != nil && m.session.Mobile != nil + m.status.Connected = false + m.status.PairingMethod = sessionPairingMethod(m.session) + m.status.AccountEmail = sessionAccountEmail(m.session) + m.status.LastError = googleReauthRequiredMessage + m.mu.Unlock() + if client != nil { + client.Disconnect() + } +} + +func cloneAuthData(session *libgm.AuthData) (*libgm.AuthData, error) { + if session == nil { + return nil, errors.New("cannot clone empty Google Messages session") + } + session.CookiesLock.RLock() + data, err := json.Marshal(session) + session.CookiesLock.RUnlock() + if err != nil { + return nil, err + } + var cloned libgm.AuthData + if err := json.Unmarshal(data, &cloned); err != nil { + return nil, err + } + return &cloned, nil +} + +func googleConfigAccount(client *libgm.Client) string { + if client == nil || client.Config == nil { + return "" + } + return strings.TrimSpace(client.Config.GetDeviceInfo().GetEmail()) +} + +// ReauthenticateGoogle replaces only the Google browser authentication on an +// existing Gaia pairing. The phone pairing, crypto keys and relay identity are +// preserved. Fresh cookies are committed only after Google confirms that they +// belong to the same account that originally paired the phone. +func (m *Manager) ReauthenticateGoogle(cookieInput string) error { + if !m.cfg.GMessagesEnabled { + return errors.New("google messages connector is disabled") + } + cookies, err := parseGoogleCookieInput(cookieInput) + if err != nil { + return err + } + + m.mu.Lock() + original := m.session + if original == nil || !original.IsGoogleAccount() || original.Browser == nil || original.Mobile == nil { + m.mu.Unlock() + return errors.New("google account pairing is not available to reauthenticate") + } + if m.status.State == "pairing" || m.status.State == "reauthenticating" { + m.mu.Unlock() + return errors.New("google messages authentication is already being changed") + } + expectedAccount := sessionAccountEmail(original) + if expectedAccount == "" { + m.mu.Unlock() + return errors.New("paired Google account identity is missing; unpair and pair again") + } + oldClient := m.client + m.client = nil + m.status.State = "reauthenticating" + m.status.Connected = false + m.status.LastError = "" + baseCtx := m.ctx + m.mu.Unlock() + + if oldClient != nil { + oldClient.Disconnect() + } + + candidate, err := cloneAuthData(original) + if err != nil { + m.logger.Error().Err(err).Msg("failed to prepare Google Messages session for reauthentication") + m.finishGoogleReauthFailure(original, "Google login refresh could not be prepared; try again") + return errors.New("google login refresh could not be prepared; try again") + } + candidate.SetCookies(cookies) + client := m.newClient(candidate) + + if baseCtx == nil { + baseCtx = context.Background() + } + probeCtx, cancel := context.WithTimeout(baseCtx, 20*time.Second) + err = client.FetchConfig(probeCtx) + cancel() + if err != nil { + client.Disconnect() + m.logger.Warn().Err(err).Msg("failed to verify refreshed Google Messages cookies") + message := "Google account authentication could not be verified; try again" + if isGoogleAuthError(err) { + message = "Google account authentication failed; refresh the browser cookies and try again" + } + m.finishGoogleReauthFailure(original, message) + return errors.New(strings.ToLower(message[:1]) + message[1:]) + } + + account := googleConfigAccount(client) + if account == "" { + client.Disconnect() + message := "Google account authentication failed; refresh the browser cookies and try again" + m.finishGoogleReauthFailure(original, message) + return errors.New(strings.ToLower(message[:1]) + message[1:]) + } + if !strings.EqualFold(account, expectedAccount) { + client.Disconnect() + message := "Google login belongs to a different account; use the account already paired with this phone" + m.finishGoogleReauthFailure(original, message) + return errors.New(strings.ToLower(message[:1]) + message[1:]) + } + + m.mu.Lock() + if m.session != original || m.status.State != "reauthenticating" { + m.mu.Unlock() + client.Disconnect() + return errors.New("google messages pairing changed while authentication was being refreshed; try again") + } + if err := saveSession(m.cfg.GMessagesSessionPath, candidate); err != nil { + m.status.State = "reauth_required" + m.status.Connected = false + m.status.LastError = "Refreshed Google authentication could not be persisted" + m.mu.Unlock() + client.Disconnect() + m.logger.Error().Err(err).Msg("failed to persist reauthenticated Google Messages session") + return errors.New("refreshed Google authentication could not be persisted") + } + m.session = candidate + m.client = client + m.status.State = "connecting" + m.status.Paired = true + m.status.Connected = false + m.status.PairingMethod = "google" + m.status.AccountEmail = expectedAccount + m.status.LastError = "" + connectCtx := m.ctx + m.mu.Unlock() + + if connectCtx != nil { + go m.connectWithBackoff(connectCtx) + } else { + m.mu.Lock() + if m.client == client && m.status.State == "connecting" { + m.status.State = "disconnected" + } + m.mu.Unlock() + } + return nil +} + +func (m *Manager) finishGoogleReauthFailure(original *libgm.AuthData, message string) { + m.mu.Lock() + defer m.mu.Unlock() + if m.session != original { + return + } + m.status.State = "reauth_required" + m.status.Paired = true + m.status.Connected = false + m.status.PairingMethod = "google" + m.status.AccountEmail = sessionAccountEmail(original) + m.status.LastError = message +} From b91606f12e060a897c13f06a9901e1f72a34e42d Mon Sep 17 00:00:00 2001 From: Sourav P Bijoy <71513365+Phloraxx@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:18:06 +0530 Subject: [PATCH 02/29] Stop auth retry loops and report real connector readiness --- internal/gmessages/manager.go | 55 +++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/internal/gmessages/manager.go b/internal/gmessages/manager.go index b906680..77c27c2 100644 --- a/internal/gmessages/manager.go +++ b/internal/gmessages/manager.go @@ -126,6 +126,10 @@ func (m *Manager) Status() Status { m.mu.RLock() defer m.mu.RUnlock() status := m.status + if status.State == "reauth_required" || status.State == "reauthenticating" { + status.Connected = false + return status + } if m.client != nil && status.Paired { status.Connected = m.client.IsConnected() if status.Connected && status.State == "disconnected" { @@ -143,6 +147,10 @@ func (m *Manager) connectWithBackoff(ctx context.Context) { } m.mu.Lock() + if m.status.State == "reauth_required" || m.status.State == "reauthenticating" { + m.mu.Unlock() + return + } if !validSession(m.session) { m.status.State = "unpaired" m.status.Paired = false @@ -154,10 +162,13 @@ func (m *Manager) connectWithBackoff(ctx context.Context) { } client := m.client m.status.State = "connecting" + m.status.Connected = false m.mu.Unlock() + // libgm starts its long-poll listener asynchronously. A nil return here + // only means the listener was started, not that Google accepted it. + // ClientReady/ListenRecovered are the authoritative connected events. if err := client.Connect(); err == nil { - m.markConnected() return } else { m.setError("degraded", err) @@ -198,6 +209,7 @@ func (m *Manager) handleEvent(raw any) { m.status.PairingEmoji = "" m.status.PairingMethod = sessionPairingMethod(m.session) m.status.AccountEmail = sessionAccountEmail(m.session) + m.status.LastError = "" m.mu.Unlock() if err := m.saveCurrentSession(); err != nil { m.logger.Error().Err(err).Msg("failed to persist Google Messages session") @@ -207,11 +219,17 @@ func (m *Manager) handleEvent(raw any) { m.logger.Error().Err(err).Msg("failed to persist refreshed Google Messages auth") } case *events.PhoneNotResponding: + if m.connectionEventsSuppressed() { + return + } m.mu.Lock() m.status.PhoneResponsive = false m.status.State = "degraded" m.mu.Unlock() case *events.PhoneRespondingAgain: + if m.connectionEventsSuppressed() { + return + } m.mu.Lock() m.status.PhoneResponsive = true m.status.State = "connected" @@ -222,11 +240,21 @@ func (m *Manager) handleEvent(raw any) { case *events.ListenRecovered: m.markConnected() case *events.ListenFatalError: + if m.handleGoogleAuthFailure(event.Error) { + return + } m.setError("degraded", event.Error) m.scheduleReconnect() case *events.PingFailed: + if m.handleGoogleAuthFailure(event.Error) { + return + } m.setError("degraded", event.Error) case *events.GaiaLoggedOut: + if m.googleAccountPairingExists() { + m.markGoogleReauthRequired() + return + } m.markLoggedOut() case *libgm.WrappedMessage: m.handleMessage(event) @@ -308,7 +336,7 @@ func normalizeTimestampMS(timestamp int64) int64 { func (m *Manager) scheduleReconnect() { m.mu.Lock() - if m.reconnecting || m.ctx == nil || !validSession(m.session) { + if m.reconnecting || m.ctx == nil || !validSession(m.session) || m.status.State == "reauth_required" || m.status.State == "reauthenticating" { m.mu.Unlock() return } @@ -334,6 +362,10 @@ func (m *Manager) scheduleReconnect() { case <-timer.C: } m.mu.Lock() + if m.status.State == "reauth_required" || m.status.State == "reauthenticating" { + m.mu.Unlock() + return + } m.client = nil m.mu.Unlock() m.connectWithBackoff(ctx) @@ -347,19 +379,28 @@ func (m *Manager) Reconnect() error { m.mu.RLock() client := m.client paired := validSession(m.session) + state := m.status.State m.mu.RUnlock() if !paired { return errors.New("google messages is not paired") } + if state == "reauth_required" || state == "reauthenticating" { + return errors.New("google account authentication must be refreshed before reconnecting") + } if client == nil { m.scheduleReconnect() return nil } + m.mu.Lock() + if m.client == client { + m.status.State = "connecting" + m.status.Connected = false + } + m.mu.Unlock() if err := client.Reconnect(); err != nil { m.setError("degraded", err) return err } - m.markConnected() return m.saveCurrentSession() } @@ -568,6 +609,10 @@ func (m *Manager) Unpair() error { func (m *Manager) markConnected() { now := time.Now().UTC() m.mu.Lock() + if m.status.State == "reauth_required" || m.status.State == "reauthenticating" { + m.mu.Unlock() + return + } m.status.State = "connected" m.status.Paired = true m.status.Connected = true @@ -597,6 +642,10 @@ func (m *Manager) markLoggedOut() { func (m *Manager) setError(state string, err error) { m.mu.Lock() + if m.status.State == "reauth_required" || m.status.State == "reauthenticating" { + m.mu.Unlock() + return + } m.status.State = state m.status.Connected = false if err != nil { From dc33e16ef06eab47f978fc3677c02f6489bc8550 Mon Sep 17 00:00:00 2001 From: Sourav P Bijoy <71513365+Phloraxx@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:20:26 +0530 Subject: [PATCH 03/29] Expose dashboard-only Google reauthentication endpoint --- internal/api/api.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/internal/api/api.go b/internal/api/api.go index e1a4c20..87c3069 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -50,6 +50,7 @@ func (a *API) Register(app core.App) { e.Router.GET("/api/dashboard", a.dashboard) e.Router.GET("/api/connector/gmessages/status", a.gmessagesStatus) e.Router.POST("/api/connector/gmessages/pair/google", a.gmessagesGooglePair).Bind(apis.BodyLimit(maxGMessagesPairBytes)) + e.Router.POST("/api/connector/gmessages/reauth/google", a.gmessagesGoogleReauth).Bind(apis.BodyLimit(maxGMessagesPairBytes)) e.Router.POST("/api/connector/gmessages/pair/qr", a.gmessagesPair) e.Router.POST("/api/connector/gmessages/pair/qr/refresh", a.gmessagesPairRefresh) // Backward-compatible QR aliases from the first PayGate rebuild. @@ -289,6 +290,23 @@ func (a *API) gmessagesGooglePair(e *core.RequestEvent) error { }) } +func (a *API) gmessagesGoogleReauth(e *core.RequestEvent) error { + if !a.dashboardAuth(e) { + return e.UnauthorizedError("dashboard authentication is required", nil) + } + if a.GMessages == nil { + return e.BadRequestError("Google Messages connector is unavailable", nil) + } + var body googleMessagesPairBody + if err := decodeJSON(e, &body); err != nil { + return e.BadRequestError("invalid JSON body", err) + } + if err := a.GMessages.ReauthenticateGoogle(strings.TrimSpace(body.CookieData)); err != nil { + return e.BadRequestError(err.Error(), nil) + } + return e.JSON(http.StatusOK, a.GMessages.Status()) +} + func (a *API) gmessagesPair(e *core.RequestEvent) error { if !a.dashboardAuth(e) { return e.UnauthorizedError("dashboard authentication is required", nil) From 02b5562caf4e1dd557a0decf838cd233cacf5b07 Mon Sep 17 00:00:00 2001 From: Sourav P Bijoy <71513365+Phloraxx@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:21:32 +0530 Subject: [PATCH 04/29] Test Google Messages reauthentication state handling --- internal/gmessages/reauth_test.go | 109 ++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 internal/gmessages/reauth_test.go diff --git a/internal/gmessages/reauth_test.go b/internal/gmessages/reauth_test.go new file mode 100644 index 0000000..14a33f4 --- /dev/null +++ b/internal/gmessages/reauth_test.go @@ -0,0 +1,109 @@ +package gmessages + +import ( + "errors" + "net/http" + "testing" + + "github.com/Phloraxx/payment-api/internal/config" + "github.com/google/uuid" + "github.com/rs/zerolog" + "go.mau.fi/mautrix-gmessages/pkg/libgm" + "go.mau.fi/mautrix-gmessages/pkg/libgm/events" + "go.mau.fi/mautrix-gmessages/pkg/libgm/gmproto" +) + +func googleTestSession(t *testing.T) *libgm.AuthData { + t.Helper() + session := libgm.NewAuthData() + session.Browser = &gmproto.Device{} + session.Mobile = &gmproto.Device{SourceID: "user@example.com"} + session.DestRegID = uuid.MustParse("00000000-0000-0000-0000-000000000001") + session.TachyonAuthToken = []byte{1, 2, 3} + cookies, err := parseGoogleCookieInput(testCookieHeader) + if err != nil { + t.Fatal(err) + } + session.SetCookies(cookies) + return session +} + +func TestGoogleAuthErrorDetection(t *testing.T) { + unauthorized := events.HTTPError{ + Action: "polling", + Resp: &http.Response{StatusCode: http.StatusUnauthorized}, + } + for name, err := range map[string]error{ + "direct 401": unauthorized, + "wrapped 401": errors.Join(errors.New("listen failed"), unauthorized), + "invalid credentials": events.ErrInvalidCredentials, + } { + t.Run(name, func(t *testing.T) { + if !isGoogleAuthError(err) { + t.Fatalf("%v was not classified as Google auth failure", err) + } + }) + } + if isGoogleAuthError(errors.New("temporary network error")) { + t.Fatal("unrelated error was classified as Google auth failure") + } +} + +func TestMarkGoogleReauthRequiredPreservesPairing(t *testing.T) { + session := googleTestSession(t) + manager := &Manager{ + cfg: config.Config{GMessagesEnabled: true}, + logger: zerolog.Nop(), + session: session, + status: Status{ + Enabled: true, State: "connected", Paired: true, Connected: true, + PairingMethod: "google", AccountEmail: "user@example.com", + }, + } + manager.markGoogleReauthRequired() + status := manager.Status() + if manager.session != session { + t.Fatal("reauth requirement replaced the existing phone pairing") + } + if status.State != "reauth_required" || !status.Paired || status.Connected { + t.Fatalf("status = %#v; want paired reauth_required and disconnected", status) + } + if status.AccountEmail != "user@example.com" || status.LastError != googleReauthRequiredMessage { + t.Fatalf("unexpected reauth status: %#v", status) + } +} + +func TestCloneAuthDataDoesNotShareCookies(t *testing.T) { + original := googleTestSession(t) + cloned, err := cloneAuthData(original) + if err != nil { + t.Fatal(err) + } + cloned.CookiesLock.Lock() + cloned.Cookies["SID"] = "changed" + cloned.CookiesLock.Unlock() + original.CookiesLock.RLock() + originalSID := original.Cookies["SID"] + original.CookiesLock.RUnlock() + if originalSID == "changed" { + t.Fatal("cloned session shares the original cookie map") + } + if sessionAccountEmail(cloned) != sessionAccountEmail(original) || cloned.PairingID != original.PairingID { + t.Fatal("cloned session did not preserve the existing pairing identity") + } +} + +func TestGoogleConfigAccount(t *testing.T) { + client := libgm.NewClient(libgm.NewAuthData(), nil, zerolog.Nop()) + client.Config = &gmproto.Config{DeviceInfo: &gmproto.Config_DeviceInfo{Email: " user@example.com "}} + if got := googleConfigAccount(client); got != "user@example.com" { + t.Fatalf("googleConfigAccount() = %q", got) + } +} + +func TestReauthenticateGoogleRequiresExistingGooglePairing(t *testing.T) { + manager := &Manager{cfg: config.Config{GMessagesEnabled: true}, logger: zerolog.Nop()} + if err := manager.ReauthenticateGoogle(testCookieHeader); err == nil { + t.Fatal("reauthentication without an existing Google pairing was accepted") + } +} From 790323a28b3fff3fd25ba3a78a14825ac522540c Mon Sep 17 00:00:00 2001 From: Sourav P Bijoy <71513365+Phloraxx@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:22:00 +0530 Subject: [PATCH 05/29] Protect Google reauthentication behind dashboard auth --- internal/api/gmessages_reauth_test.go | 38 +++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 internal/api/gmessages_reauth_test.go diff --git a/internal/api/gmessages_reauth_test.go b/internal/api/gmessages_reauth_test.go new file mode 100644 index 0000000..183f17c --- /dev/null +++ b/internal/api/gmessages_reauth_test.go @@ -0,0 +1,38 @@ +package api + +import ( + "net/http" + "strings" + "testing" + + "github.com/pocketbase/pocketbase/tests" +) + +func TestGoogleMessagesReauthEndpointRequiresDashboardAuth(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "reauth requires dashboard auth", Method: http.MethodPost, + URL: "/api/connector/gmessages/reauth/google", + Headers: map[string]string{"Content-Type": "application/json"}, + Body: strings.NewReader(`{"cookieData":"SID=missing-rest"}`), + TestAppFactory: func(t testing.TB) *tests.TestApp { return apiTestFactoryWithGMessages(t) }, + ExpectedStatus: http.StatusUnauthorized, + ExpectedContent: []string{"Dashboard authentication is required."}, + }, + { + Name: "payment API key cannot reauthenticate Google Messages", Method: http.MethodPost, + URL: "/api/connector/gmessages/reauth/google", + Headers: map[string]string{ + "Authorization": "Bearer api-secret", + "Content-Type": "application/json", + }, + Body: strings.NewReader(`{"cookieData":"SID=missing-rest"}`), + TestAppFactory: func(t testing.TB) *tests.TestApp { return apiTestFactoryWithGMessages(t) }, + ExpectedStatus: http.StatusUnauthorized, + ExpectedContent: []string{"Dashboard authentication is required."}, + }, + } + for i := range scenarios { + scenarios[i].Test(t) + } +} From f9e6d7a60387de8954980e0605001d2c967cfeda Mon Sep 17 00:00:00 2001 From: Sourav P Bijoy <71513365+Phloraxx@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:23:02 +0530 Subject: [PATCH 06/29] Add Google login refresh flow to connector settings --- web/src/pages/Settings.tsx | 51 +++++++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 2375981..b2743f0 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -42,7 +42,8 @@ export function Settings({ notify }: { notify: (value: string) => void }) { setPairingEmoji(status.pairingEmoji ?? ""); setPairingAccount(status.accountEmail ?? ""); } - if (status.paired) { + const refreshingGoogleAuth = status.state === "reauth_required" || status.state === "reauthenticating"; + if (status.paired && !refreshingGoogleAuth) { setCookieData(""); setPairingEmoji(""); setQrUrl(""); @@ -101,6 +102,27 @@ export function Settings({ notify }: { notify: (value: string) => void }) { } } + async function refreshGoogleLogin() { + if (!cookieData.trim()) { + notify("Paste a fresh Google Messages Copy-as-cURL request first."); + return; + } + setBusy(true); + try { + const status = await api("/api/connector/gmessages/reauth/google", { + method: "POST", + body: JSON.stringify({ cookieData }), + }); + setCookieData(""); + setConnector(status); + notify("Google login refreshed. Reconnecting with the existing phone pairing."); + } catch (err) { + notify(err instanceof Error ? err.message : "Google login could not be refreshed."); + } finally { + setBusy(false); + } + } + async function startQRPairing() { setBusy(true); try { @@ -140,10 +162,14 @@ export function Settings({ notify }: { notify: (value: string) => void }) { const enabled = connector?.enabled ?? false; const pairing = connector?.state === "pairing"; + const googleReauth = connector?.paired && connector.pairingMethod === "google" && + (connector.state === "reauth_required" || connector.state === "reauthenticating"); + const displayState = connector?.state?.replaceAll("_", " ") ?? "loading"; + return <>
-

GOOGLE MESSAGES

{enabled ? connector?.state ?? "loading" : "disabled"}

+

GOOGLE MESSAGES

{enabled ? displayState : "disabled"}

{connector?.lastError || "Read-only SMS connector. Google account + emoji pairing is preferred; QR remains available as a fallback."}

@@ -175,6 +201,25 @@ export function Settings({ notify }: { notify: (value: string) => void }) { } + {googleReauth &&
+

Refresh Google login

+

The phone pairing and encryption keys are still saved. Only the Google browser login expired, so no emoji or new device pairing is required.

+

Open Google Messages Web with {connector?.accountEmail || "the already paired Google account"}, then DevTools → Network → reload → configCopy as cURL. Paste the fresh request below.

+

Open Google Messages account/config ↗

+