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

Recover stale tabs after a deploy or expired session (#467)

* Recover stale tabs: sign-in on lost session, reload toast on new build

A tab left open across a deploy and an expired session ignored clicks until Next reloaded it. The tab now goes to sign-in when its session is gone and comes back to the same page, and offers a reload when the server runs a newer build. Reports date picker gets month and year dropdowns.

* Show the reload toast when a caught server action is gone

Watch Next's action-not-found header on every fetch instead of relying on callers to report it. Drops the unused reportIfOutdated helper.
Bernt Christian Egeland 6 дней назад
Родитель
Сommit
800dc5a93d

+ 96 - 0
e2e/specs/auth/stale-tab.spec.ts

@@ -0,0 +1,96 @@
+import { expect, type Page, test } from '@playwright/test'
+import { settle } from '../../support/hydration'
+
+/**
+ * A tab left open for days. Found in PostHog on 27 Oct 2026: a workshop came
+ * back to a dashboard from before a deploy, with its session gone, and clicked
+ * Work orders five times over 16 seconds with nothing on screen before Next
+ * reloaded the page and the layout sent them to sign-in. They then landed on
+ * the dashboard rather than the page they wanted.
+ *
+ * The session is ended by dropping this context's session cookie, which is
+ * what the server sees once a session expires, and leaves the shared owner
+ * session in e2e/.auth/owner.json alone.
+ */
+
+const email = process.env.E2E_USER_EMAIL ?? 'demo@torqvoice.com'
+const password = process.env.E2E_USER_PASSWORD ?? 'demo-e2e-pass'
+
+/** What a browser does when someone switches back to the tab. */
+async function bringTabBack(page: Page) {
+  await page.evaluate(() => document.dispatchEvent(new Event('visibilitychange')))
+}
+
+async function signIn(page: Page) {
+  await page.locator('#email').fill(email)
+  await page.locator('#password').fill(password)
+  await page.getByRole('button', { name: 'Sign In', exact: true }).click()
+}
+
+test.describe('a tab that went stale', () => {
+  test('offers a reload once the server runs a newer build', async ({ page }) => {
+    await page.route('**/api/public/version', (route) =>
+      route.fulfill({ json: { version: 'a-newer-build' } })
+    )
+    await page.goto('/work-orders')
+    await settle(page)
+
+    const toast = page
+      .locator('[data-sonner-toast]')
+      .filter({ hasText: 'A new version is available' })
+    await expect(async () => {
+      await bringTabBack(page)
+      await expect(toast).toBeVisible({ timeout: 2_000 })
+    }).toPass()
+
+    const reloaded = page.waitForEvent('load')
+    await toast.getByRole('button', { name: 'Reload' }).click()
+    await reloaded
+    await expect(page).toHaveURL(/\/work-orders$/)
+  })
+
+  test('says nothing while the build is the same', async ({ page }) => {
+    const asked = page.waitForRequest('**/api/public/version')
+    await page.goto('/work-orders')
+    await settle(page)
+    await bringTabBack(page)
+    await asked
+
+    await expect(page.locator('[data-sonner-toast]')).toHaveCount(0)
+  })
+
+  test('goes to sign-in when the tab comes back without a session, and returns', async ({
+    page,
+    context,
+  }) => {
+    await page.goto('/work-orders?status=pending')
+    await settle(page)
+
+    await context.clearCookies({ name: /session_token/ })
+    await bringTabBack(page)
+
+    await page.waitForURL(/\/auth\/sign-in\?redirect=%2Fwork-orders%3Fstatus%3Dpending/)
+    await settle(page)
+    await expect(async () => {
+      await signIn(page)
+      await page.waitForURL(/\/work-orders\?status=pending$/, { timeout: 5_000 })
+    }).toPass()
+  })
+
+  test('a page opened without a session comes back to itself after sign-in', async ({
+    browser,
+  }) => {
+    const context = await browser.newContext({ storageState: { cookies: [], origins: [] } })
+    const page = await context.newPage()
+
+    await page.goto('/work-orders?status=pending')
+    await page.waitForURL(/\/auth\/sign-in\?redirect=%2Fwork-orders%3Fstatus%3Dpending/)
+    await settle(page)
+    await expect(async () => {
+      await signIn(page)
+      await page.waitForURL(/\/work-orders\?status=pending$/, { timeout: 5_000 })
+    }).toPass()
+
+    await context.close()
+  })
+})

+ 10 - 0
messages/de/common.json

@@ -135,5 +135,15 @@
   },
   "richText": {
     "openLinkHint": "{key} gedrückt halten und klicken, um zu öffnen"
+  },
+  "appUpdated": {
+    "title": "Eine neue Version ist verfügbar",
+    "description": "Laden Sie die Seite neu, um die neuesten Änderungen zu erhalten.",
+    "reload": "Neu laden"
+  },
+  "signedOut": {
+    "title": "Sie wurden abgemeldet",
+    "description": "Melden Sie sich erneut an, um weiterzuarbeiten. Kopieren Sie vorher, was Sie gerade eingegeben haben.",
+    "signIn": "Anmelden"
   }
 }

+ 10 - 0
messages/en/common.json

@@ -135,5 +135,15 @@
   },
   "richText": {
     "openLinkHint": "Hold {key} and click to open"
+  },
+  "appUpdated": {
+    "title": "A new version is available",
+    "description": "Reload the page to get the latest updates.",
+    "reload": "Reload"
+  },
+  "signedOut": {
+    "title": "You have been signed out",
+    "description": "Sign in again to keep working. Copy anything you were typing first.",
+    "signIn": "Sign in"
   }
 }

+ 10 - 0
messages/es/common.json

@@ -135,5 +135,15 @@
   },
   "richText": {
     "openLinkHint": "Mantén pulsado {key} y haz clic para abrir"
+  },
+  "appUpdated": {
+    "title": "Hay una nueva versión disponible",
+    "description": "Recarga la página para obtener las últimas novedades.",
+    "reload": "Recargar"
+  },
+  "signedOut": {
+    "title": "Se ha cerrado tu sesión",
+    "description": "Inicia sesión de nuevo para seguir trabajando. Copia primero lo que estabas escribiendo.",
+    "signIn": "Iniciar sesión"
   }
 }

+ 10 - 0
messages/fr/common.json

@@ -135,5 +135,15 @@
   },
   "richText": {
     "openLinkHint": "Maintenez {key} et cliquez pour ouvrir"
+  },
+  "appUpdated": {
+    "title": "Une nouvelle version est disponible",
+    "description": "Rechargez la page pour obtenir les dernières nouveautés.",
+    "reload": "Recharger"
+  },
+  "signedOut": {
+    "title": "Votre session a expiré",
+    "description": "Reconnectez-vous pour continuer. Copiez d'abord ce que vous étiez en train de saisir.",
+    "signIn": "Se connecter"
   }
 }

+ 10 - 0
messages/it/common.json

@@ -135,5 +135,15 @@
   },
   "richText": {
     "openLinkHint": "Tieni premuto {key} e fai clic per aprire"
+  },
+  "appUpdated": {
+    "title": "È disponibile una nuova versione",
+    "description": "Ricarica la pagina per ottenere gli ultimi aggiornamenti.",
+    "reload": "Ricarica"
+  },
+  "signedOut": {
+    "title": "La sessione è scaduta",
+    "description": "Accedi di nuovo per continuare a lavorare. Copia prima ciò che stavi scrivendo.",
+    "signIn": "Accedi"
   }
 }

+ 10 - 0
messages/lt/common.json

@@ -135,5 +135,15 @@
   },
   "richText": {
     "openLinkHint": "Laikykite {key} ir spustelėkite, kad atidarytumėte"
+  },
+  "appUpdated": {
+    "title": "Yra nauja versija",
+    "description": "Perkraukite puslapį, kad gautumėte naujausius atnaujinimus.",
+    "reload": "Perkrauti"
+  },
+  "signedOut": {
+    "title": "Sesija baigėsi",
+    "description": "Prisijunkite iš naujo, kad galėtumėte tęsti darbą. Pirmiausia nukopijuokite tai, ką rašėte.",
+    "signIn": "Prisijungti"
   }
 }

+ 10 - 0
messages/nb/common.json

@@ -135,5 +135,15 @@
   },
   "richText": {
     "openLinkHint": "Hold {key} og klikk for å åpne"
+  },
+  "appUpdated": {
+    "title": "En ny versjon er tilgjengelig",
+    "description": "Last inn siden på nytt for å få de siste oppdateringene.",
+    "reload": "Last inn på nytt"
+  },
+  "signedOut": {
+    "title": "Du er logget ut",
+    "description": "Logg inn igjen for å fortsette. Kopier det du holdt på å skrive først.",
+    "signIn": "Logg inn"
   }
 }

+ 10 - 0
messages/nl/common.json

@@ -135,5 +135,15 @@
   },
   "richText": {
     "openLinkHint": "Houd {key} ingedrukt en klik om te openen"
+  },
+  "appUpdated": {
+    "title": "Er is een nieuwe versie beschikbaar",
+    "description": "Laad de pagina opnieuw om de nieuwste updates te krijgen.",
+    "reload": "Opnieuw laden"
+  },
+  "signedOut": {
+    "title": "Je bent afgemeld",
+    "description": "Meld je opnieuw aan om verder te werken. Kopieer eerst wat je aan het typen was.",
+    "signIn": "Aanmelden"
   }
 }

+ 10 - 0
messages/pl/common.json

@@ -135,5 +135,15 @@
   },
   "richText": {
     "openLinkHint": "Przytrzymaj {key} i kliknij, aby otworzyć"
+  },
+  "appUpdated": {
+    "title": "Dostępna jest nowa wersja",
+    "description": "Odśwież stronę, aby pobrać najnowsze aktualizacje.",
+    "reload": "Odśwież"
+  },
+  "signedOut": {
+    "title": "Sesja wygasła",
+    "description": "Zaloguj się ponownie, aby kontynuować pracę. Najpierw skopiuj wpisany tekst.",
+    "signIn": "Zaloguj się"
   }
 }

+ 10 - 0
messages/pt-BR/common.json

@@ -135,5 +135,15 @@
   },
   "richText": {
     "openLinkHint": "Segure {key} e clique para abrir"
+  },
+  "appUpdated": {
+    "title": "Uma nova versão está disponível",
+    "description": "Recarregue a página para obter as atualizações mais recentes.",
+    "reload": "Recarregar"
+  },
+  "signedOut": {
+    "title": "Sua sessão foi encerrada",
+    "description": "Entre novamente para continuar trabalhando. Copie antes o que estava digitando.",
+    "signIn": "Entrar"
   }
 }

+ 10 - 0
messages/pt-PT/common.json

@@ -135,5 +135,15 @@
   },
   "richText": {
     "openLinkHint": "Mantenha {key} premido e clique para abrir"
+  },
+  "appUpdated": {
+    "title": "Está disponível uma nova versão",
+    "description": "Recarregue a página para obter as atualizações mais recentes.",
+    "reload": "Recarregar"
+  },
+  "signedOut": {
+    "title": "A sua sessão terminou",
+    "description": "Inicie sessão novamente para continuar a trabalhar. Copie primeiro o que estava a escrever.",
+    "signIn": "Iniciar sessão"
   }
 }

+ 10 - 0
messages/ru/common.json

@@ -135,5 +135,15 @@
   },
   "richText": {
     "openLinkHint": "Удерживайте {key} и нажмите, чтобы открыть"
+  },
+  "appUpdated": {
+    "title": "Доступна новая версия",
+    "description": "Перезагрузите страницу, чтобы получить последние обновления.",
+    "reload": "Перезагрузить"
+  },
+  "signedOut": {
+    "title": "Сеанс завершён",
+    "description": "Войдите снова, чтобы продолжить работу. Сначала скопируйте то, что вы вводили.",
+    "signIn": "Войти"
   }
 }

+ 10 - 0
messages/tr/common.json

@@ -135,5 +135,15 @@
   },
   "richText": {
     "openLinkHint": "Açmak için {key} tuşunu basılı tutup tıklayın"
+  },
+  "appUpdated": {
+    "title": "Yeni bir sürüm mevcut",
+    "description": "En son güncellemeleri almak için sayfayı yeniden yükleyin.",
+    "reload": "Yeniden yükle"
+  },
+  "signedOut": {
+    "title": "Oturumunuz kapandı",
+    "description": "Çalışmaya devam etmek için yeniden giriş yapın. Önce yazmakta olduğunuz metni kopyalayın.",
+    "signIn": "Giriş yap"
   }
 }

+ 91 - 0
src/__tests__/components/online-tracker.test.tsx

@@ -0,0 +1,91 @@
+import { act, render } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const toast = vi.fn()
+vi.mock('sonner', () => ({ toast: (...args: unknown[]) => toast(...args) }))
+
+import { OnlineTracker } from '@/components/online-tracker'
+
+const assign = vi.fn()
+
+function answerPing(status: number) {
+  const fetchMock = vi.fn(async () => new Response('{}', { status }))
+  vi.stubGlobal('fetch', fetchMock)
+  return fetchMock
+}
+
+async function flush() {
+  await act(async () => {
+    await Promise.resolve()
+    await Promise.resolve()
+  })
+}
+
+describe('OnlineTracker', () => {
+  beforeEach(() => {
+    toast.mockReset()
+    assign.mockReset()
+    vi.stubGlobal('location', {
+      ...window.location,
+      pathname: '/work-orders',
+      search: '?status=pending',
+      assign,
+    })
+  })
+  afterEach(() => {
+    vi.unstubAllGlobals()
+  })
+
+  it('pings while the session is alive and stays put', async () => {
+    const fetchMock = answerPing(200)
+    render(<OnlineTracker />)
+    await flush()
+
+    expect(fetchMock).toHaveBeenCalledWith('/api/protected/ping', { method: 'POST' })
+    expect(assign).not.toHaveBeenCalled()
+    expect(toast).not.toHaveBeenCalled()
+  })
+
+  it('sends an idle tab whose session is gone to sign-in, set to come back', async () => {
+    answerPing(401)
+    render(<OnlineTracker />)
+    await flush()
+
+    expect(assign).toHaveBeenCalledWith('/auth/sign-in?redirect=%2Fwork-orders%3Fstatus%3Dpending')
+    expect(toast).not.toHaveBeenCalled()
+  })
+
+  it('asks instead of leaving when someone is typing', async () => {
+    answerPing(200)
+    render(<OnlineTracker />)
+    await flush()
+
+    window.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }))
+    answerPing(401)
+    await act(async () => {
+      document.dispatchEvent(new Event('visibilitychange'))
+    })
+    await flush()
+
+    expect(assign).not.toHaveBeenCalled()
+    expect(toast).toHaveBeenCalledTimes(1)
+    const [title, options] = toast.mock.calls[0]
+    expect(title).toBe('You have been signed out')
+    options.action.onClick()
+    expect(assign).toHaveBeenCalledWith('/auth/sign-in?redirect=%2Fwork-orders%3Fstatus%3Dpending')
+  })
+
+  it('does nothing when the ping cannot reach the server', async () => {
+    vi.stubGlobal(
+      'fetch',
+      vi.fn(async () => {
+        throw new TypeError('Failed to fetch')
+      })
+    )
+    render(<OnlineTracker />)
+    await flush()
+
+    expect(assign).not.toHaveBeenCalled()
+    expect(toast).not.toHaveBeenCalled()
+  })
+})

+ 169 - 0
src/__tests__/components/update-watcher.test.tsx

@@ -0,0 +1,169 @@
+import { act, render } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const toast = vi.fn()
+vi.mock('sonner', () => ({ toast: (...args: unknown[]) => toast(...args) }))
+
+import {
+  ACTION_NOT_FOUND_HEADER,
+  isOutdatedActionError,
+  UpdateWatcher,
+} from '@/components/update-watcher'
+
+/**
+ * The server as an old tab sees it after a deploy: the version route answers
+ * with the build it was loaded from, so only the action's answer gives the
+ * deploy away.
+ */
+function answerActionNotFound() {
+  const fetchMock = vi.fn(async (input: RequestInfo | URL) =>
+    String(input).includes('/api/public/version')
+      ? new Response(JSON.stringify({ version: 'build-1' }))
+      : new Response('', { status: 404, headers: { [ACTION_NOT_FOUND_HEADER]: '1' } })
+  )
+  vi.stubGlobal('fetch', fetchMock)
+  return fetchMock
+}
+
+/** A save the way the app's handlers write it: the failure is caught and logged. */
+async function saveAndCatch() {
+  try {
+    const response = await window.fetch('/work-orders', {
+      method: 'POST',
+      headers: { 'next-action': 'abc123' },
+    })
+    if (response.headers.get(ACTION_NOT_FOUND_HEADER) === '1') {
+      throw new Error('Server Action "abc123" was not found on the server.')
+    }
+  } catch {
+    // What reports-client and most other callers do with it
+  }
+}
+
+function answerVersion(version: string) {
+  const fetchMock = vi.fn(async () => new Response(JSON.stringify({ version })))
+  vi.stubGlobal('fetch', fetchMock)
+  return fetchMock
+}
+
+async function flush() {
+  await act(async () => {
+    await Promise.resolve()
+    await Promise.resolve()
+  })
+}
+
+function bringTabBack() {
+  document.dispatchEvent(new Event('visibilitychange'))
+}
+
+describe('isOutdatedActionError', () => {
+  it('recognises the error Next throws for a server action of an older build', () => {
+    const named = Object.assign(new Error('x'), { name: 'UnrecognizedActionError' })
+    expect(isOutdatedActionError(named)).toBe(true)
+    expect(
+      isOutdatedActionError(new Error('Server Action "abc" was not found on the server.'))
+    ).toBe(true)
+    expect(isOutdatedActionError(new Error('Failed to find Server Action "abc"'))).toBe(true)
+  })
+
+  it('leaves every other error alone', () => {
+    expect(isOutdatedActionError(new Error('Unauthorized'))).toBe(false)
+    expect(isOutdatedActionError('Server Action was not found on the server')).toBe(false)
+  })
+})
+
+describe('UpdateWatcher', () => {
+  beforeEach(() => {
+    toast.mockReset()
+    vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'Date'] })
+  })
+  afterEach(() => {
+    vi.useRealTimers()
+    vi.unstubAllGlobals()
+  })
+
+  it('offers a reload when the server runs another build', async () => {
+    answerVersion('build-2')
+    render(<UpdateWatcher version="build-1" />)
+    bringTabBack()
+    await flush()
+
+    expect(toast).toHaveBeenCalledTimes(1)
+    const [title, options] = toast.mock.calls[0]
+    expect(title).toBe('A new version is available')
+    expect(options.action.label).toBe('Reload')
+    expect(options.duration).toBe(Number.POSITIVE_INFINITY)
+  })
+
+  it('says nothing while the build is the same', async () => {
+    const fetchMock = answerVersion('build-1')
+    render(<UpdateWatcher version="build-1" />)
+    bringTabBack()
+    await flush()
+
+    expect(fetchMock).toHaveBeenCalledWith('/api/public/version', { cache: 'no-store' })
+    expect(toast).not.toHaveBeenCalled()
+  })
+
+  it('also asks every five minutes while the tab stays open', async () => {
+    const fetchMock = answerVersion('build-2')
+    render(<UpdateWatcher version="build-1" />)
+    await act(async () => {
+      vi.advanceTimersByTime(5 * 60 * 1000)
+    })
+    await flush()
+
+    expect(fetchMock).toHaveBeenCalledTimes(1)
+    expect(toast).toHaveBeenCalledTimes(1)
+  })
+
+  it('never asks on the dev server', async () => {
+    const fetchMock = answerVersion('build-2')
+    render(<UpdateWatcher version="development" />)
+    bringTabBack()
+    await flush()
+
+    expect(fetchMock).not.toHaveBeenCalled()
+  })
+
+  it('shows the toast at once when a caught save hits a server action that is gone', async () => {
+    answerActionNotFound()
+    render(<UpdateWatcher version="build-1" />)
+    await saveAndCatch()
+    await flush()
+
+    expect(toast).toHaveBeenCalledTimes(1)
+    expect(toast.mock.calls[0][0]).toBe('A new version is available')
+  })
+
+  it('hands every response back untouched', async () => {
+    answerActionNotFound()
+    render(<UpdateWatcher version="build-1" />)
+    const response = await window.fetch('/work-orders', { method: 'POST' })
+
+    expect(response.status).toBe(404)
+    expect(response.headers.get(ACTION_NOT_FOUND_HEADER)).toBe('1')
+  })
+
+  it('says nothing for an ordinary failed request', async () => {
+    vi.stubGlobal(
+      'fetch',
+      vi.fn(async () => new Response('', { status: 500 }))
+    )
+    render(<UpdateWatcher version="build-1" />)
+    await window.fetch('/work-orders', { method: 'POST' })
+    await flush()
+
+    expect(toast).not.toHaveBeenCalled()
+  })
+
+  it('puts fetch back when it unmounts', () => {
+    const fetchMock = answerActionNotFound()
+    const { unmount } = render(<UpdateWatcher version="build-1" />)
+    expect(window.fetch).not.toBe(fetchMock)
+
+    unmount()
+    expect(window.fetch).toBe(fetchMock)
+  })
+})

+ 75 - 0
src/__tests__/lib/sign-in-redirect.test.ts

@@ -0,0 +1,75 @@
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('server-only', () => ({}))
+
+const headerValues = new Map<string, string>()
+vi.mock('next/headers', () => ({
+  headers: async () => ({ get: (name: string) => headerValues.get(name) ?? null }),
+}))
+
+const redirect = vi.fn((url: string) => {
+  throw new Error(`NEXT_REDIRECT ${url}`)
+})
+vi.mock('next/navigation', () => ({ redirect: (url: string) => redirect(url) }))
+
+import proxy from '@/proxy'
+import { REQUEST_PATH_HEADER } from '@/lib/request-path'
+import { redirectToSignIn } from '@/lib/sign-in-redirect.server'
+
+describe('proxy on a page request', () => {
+  function forwardedPath(url: string, init?: ConstructorParameters<typeof NextRequest>[1]) {
+    const response = proxy(new NextRequest(url, init))
+    // NextResponse.next({ request: { headers } }) hands the overrides on as
+    // x-middleware-request-* response headers.
+    return response?.headers.get(`x-middleware-request-${REQUEST_PATH_HEADER}`)
+  }
+
+  it('passes on the path and query the browser asked for', () => {
+    expect(forwardedPath('https://app.example/work-orders?status=pending')).toBe(
+      '/work-orders?status=pending'
+    )
+  })
+
+  it("drops Next's own _rsc marker from a client navigation", () => {
+    expect(forwardedPath('https://app.example/work-orders?_rsc=abc12')).toBe('/work-orders')
+  })
+
+  it('overwrites a value the client sent itself', () => {
+    expect(
+      forwardedPath('https://app.example/quotes', {
+        headers: { [REQUEST_PATH_HEADER]: '//evil.example' },
+      })
+    ).toBe('/quotes')
+  })
+
+  it('leaves the API to its own handling', () => {
+    expect(forwardedPath('https://app.example/api/protected/ping')).toBeFalsy()
+  })
+})
+
+describe('redirectToSignIn', () => {
+  beforeEach(() => {
+    headerValues.clear()
+    redirect.mockClear()
+  })
+
+  it('sends the visitor to sign-in, set to come back to the page they asked for', async () => {
+    headerValues.set(REQUEST_PATH_HEADER, '/work-orders?status=pending')
+    await expect(redirectToSignIn()).rejects.toThrow('NEXT_REDIRECT')
+    expect(redirect).toHaveBeenCalledWith(
+      '/auth/sign-in?redirect=%2Fwork-orders%3Fstatus%3Dpending'
+    )
+  })
+
+  it('is the plain sign-in page without the header', async () => {
+    await expect(redirectToSignIn()).rejects.toThrow('NEXT_REDIRECT')
+    expect(redirect).toHaveBeenCalledWith('/auth/sign-in')
+  })
+
+  it('never sends anyone off the site', async () => {
+    headerValues.set(REQUEST_PATH_HEADER, '//evil.example/x')
+    await expect(redirectToSignIn()).rejects.toThrow('NEXT_REDIRECT')
+    expect(redirect).toHaveBeenCalledWith('/auth/sign-in')
+  })
+})

+ 35 - 0
src/__tests__/lib/signed-out.test.ts

@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest'
+import { leaveForSignInNow, RECENT_INPUT_MS, signInUrlFor } from '@/lib/signed-out'
+
+describe('signInUrlFor', () => {
+  it('comes back to the page the tab was on, query included', () => {
+    expect(signInUrlFor('/work-orders', '?status=pending')).toBe(
+      '/auth/sign-in?redirect=%2Fwork-orders%3Fstatus%3Dpending'
+    )
+  })
+
+  it('is the plain sign-in page from the dashboard', () => {
+    expect(signInUrlFor('/')).toBe('/auth/sign-in')
+  })
+
+  it('never carries a target off this site', () => {
+    expect(signInUrlFor('//evil.example/x')).toBe('/auth/sign-in')
+    expect(signInUrlFor('/\\evil.example')).toBe('/auth/sign-in')
+  })
+})
+
+describe('leaveForSignInNow', () => {
+  const now = 10_000_000
+
+  it('leaves at once when nobody has typed', () => {
+    expect(leaveForSignInNow(0, now)).toBe(true)
+  })
+
+  it('waits for someone who typed just now', () => {
+    expect(leaveForSignInNow(now - 5_000, now)).toBe(false)
+  })
+
+  it('leaves once the last keystroke is over a minute old', () => {
+    expect(leaveForSignInNow(now - RECENT_INPUT_MS - 1, now)).toBe(true)
+  })
+})

+ 5 - 1
src/app/(authenticated)/layout.tsx

@@ -19,6 +19,9 @@ import { CurrencySettingsProvider } from '@/components/currency-settings-context
 import { getCachedMembership } from '@/lib/cached-session'
 import { hasPermission, PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { OnlineTracker } from '@/components/online-tracker'
+import { UpdateWatcher } from '@/components/update-watcher'
+import { buildVersion } from '@/lib/build-version.server'
+import { redirectToSignIn } from '@/lib/sign-in-redirect.server'
 import { InstallBanner } from '@/components/pwa-install-prompt'
 import { MobileBottomNav } from '@/components/mobile-bottom-nav'
 import { SupportBubble } from '@/features/support/Components/SupportBubble'
@@ -57,7 +60,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
   // dashboard grid).
   const sidebarOpen = (await cookies()).get('sidebar_state')?.value !== 'false'
 
-  if (data.status === 'unauthenticated') redirect('/auth/sign-in')
+  if (data.status === 'unauthenticated') return redirectToSignIn()
   if (data.status === 'no-organization') redirect('/onboarding')
 
   // Check email verification requirement (super admins bypass this)
@@ -363,6 +366,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
                             <PlateLookupCommand />
                             {isOwnerOrAdmin && <NotificationInitializer />}
                             <OnlineTracker />
+                            <UpdateWatcher version={buildVersion()} />
                             <InstallBanner />
                           </TimeClockProvider>
                         </RealtimeProvider>

+ 6 - 0
src/app/(authenticated)/reports/reports-client.tsx

@@ -883,6 +883,12 @@ export default function ReportsClient({
                     selected={pendingDateRange}
                     onSelect={(range) => range && setPendingDateRange(range)}
                     numberOfMonths={2}
+                    // Month and year as dropdowns: reports often span a
+                    // financial year, and stepping back to last July one
+                    // arrow click at a time is what people rage-clicked.
+                    captionLayout="dropdown"
+                    startMonth={new Date(2000, 0)}
+                    endMonth={new Date(currentYear, new Date().getMonth())}
                   />
                   <div className="border-l p-2 flex flex-col gap-0.5 min-w-[130px]">
                     {(() => {

+ 17 - 0
src/app/api/public/version/route.ts

@@ -0,0 +1,17 @@
+import { NextResponse } from 'next/server'
+import { buildVersion } from '@/lib/build-version.server'
+
+export const dynamic = 'force-dynamic'
+
+/**
+ * Which build is running. Open tabs ask now and then and offer a reload when
+ * the answer is not the build they were loaded from. A route and not a server
+ * action: an action of the old build is exactly what stops working after a
+ * deploy. Public, because a tab whose session ran out still needs the answer.
+ */
+export function GET() {
+  return NextResponse.json(
+    { version: buildVersion() },
+    { headers: { 'Cache-Control': 'no-store' } }
+  )
+}

+ 58 - 7
src/components/online-tracker.tsx

@@ -1,23 +1,74 @@
 'use client'
+import { useTranslations } from 'next-intl'
 import { useEffect } from 'react'
+import { toast } from 'sonner'
+import { leaveForSignInNow, signInUrlFor } from '@/lib/signed-out'
 
+const SIGNED_OUT_TOAST_ID = 'signed-out'
+
+/**
+ * Marks the account as online, and notices when its session is gone.
+ *
+ * The ping answers 401 once the session has run out or was signed out from
+ * another device. Without this a tab left open for days still looks signed in,
+ * and Next does not run the layout's sign-in redirect on a client navigation,
+ * so clicks into the app showed nothing useful until a full reload. Checked on
+ * mount, every minute and whenever the tab comes back to the front.
+ */
 export function OnlineTracker() {
+  const t = useTranslations('common.signedOut')
+
   useEffect(() => {
-    const ping = () => {
-      if (document.visibilityState === 'visible') {
-        fetch('/api/protected/ping', { method: 'POST' })
+    let lastInputAt = 0
+    let signedOut = false
+
+    const goToSignIn = () => {
+      window.location.assign(signInUrlFor(window.location.pathname, window.location.search))
+    }
+
+    const onSignedOut = () => {
+      if (signedOut) return
+      signedOut = true
+      if (leaveForSignInNow(lastInputAt, Date.now())) {
+        goToSignIn()
+        return
       }
+      // Typing just now: a page change would lose it, so ask instead
+      toast(t('title'), {
+        id: SIGNED_OUT_TOAST_ID,
+        description: t('description'),
+        duration: Number.POSITIVE_INFINITY,
+        action: { label: t('signIn'), onClick: goToSignIn },
+      })
+    }
+
+    const ping = async () => {
+      if (signedOut || document.visibilityState !== 'visible') return
+      try {
+        const response = await fetch('/api/protected/ping', { method: 'POST' })
+        if (response.status === 401) onSignedOut()
+      } catch {
+        // Offline or mid-deploy: the next ping will tell
+      }
+    }
+
+    const onInput = () => {
+      lastInputAt = Date.now()
     }
 
-    ping() // ping immediately on mount
-    const interval = setInterval(ping, 60_000) // every 60 seconds
-    document.addEventListener('visibilitychange', ping) // ping on tab focus
+    void ping()
+    const interval = setInterval(ping, 60_000)
+    document.addEventListener('visibilitychange', ping)
+    window.addEventListener('keydown', onInput, { capture: true })
+    window.addEventListener('input', onInput, { capture: true })
 
     return () => {
       clearInterval(interval)
       document.removeEventListener('visibilitychange', ping)
+      window.removeEventListener('keydown', onInput, { capture: true })
+      window.removeEventListener('input', onInput, { capture: true })
     }
-  }, [])
+  }, [t])
 
   return null
 }

+ 109 - 0
src/components/update-watcher.tsx

@@ -0,0 +1,109 @@
+'use client'
+
+import { useTranslations } from 'next-intl'
+import { useEffect } from 'react'
+import { toast } from 'sonner'
+
+const TOAST_ID = 'app-update'
+/**
+ * The header Next's server puts on its answer to a server action it does not
+ * have, which is what an old tab's actions get after a deploy. Next's client
+ * reads it and throws, but callers catch that error to show their own message,
+ * so the header is watched instead: it is seen on every action call, caught
+ * or not.
+ */
+export const ACTION_NOT_FOUND_HEADER = 'x-nextjs-action-not-found'
+/** How often an open tab asks which build is running */
+const POLL_MS = 5 * 60 * 1000
+/** A tab coming back to the front asks at most this often */
+const FOCUS_MIN_GAP_MS = 30 * 1000
+
+/**
+ * The error Next.js throws in the browser when a server action of this page
+ * is not on the server any more. Matched by name and text rather than with
+ * unstable_isUnrecognizedActionError, which is an unstable export.
+ */
+export function isOutdatedActionError(error: unknown): boolean {
+  if (!(error instanceof Error)) return false
+  return (
+    error.name === 'UnrecognizedActionError' ||
+    /Server Action .* was not found on the server|Failed to find Server Action/.test(error.message)
+  )
+}
+
+/**
+ * Notices a deploy while a page is open and offers a reload. Without it a tab
+ * left open for days keeps the old build: its saves call server actions that
+ * are gone, and a click on a link waits for a whole page render before Next
+ * gives up and reloads, with nothing on screen in between.
+ */
+export function UpdateWatcher({ version }: { version: string }) {
+  const t = useTranslations('common.appUpdated')
+
+  useEffect(() => {
+    if (version === 'development' || version === 'unknown') return
+
+    let shown = false
+    let lastCheck = 0
+
+    const show = () => {
+      if (shown) return
+      shown = true
+      toast(t('title'), {
+        id: TOAST_ID,
+        description: t('description'),
+        duration: Number.POSITIVE_INFINITY,
+        action: { label: t('reload'), onClick: () => window.location.reload() },
+      })
+    }
+
+    const check = async () => {
+      if (shown) return
+      lastCheck = Date.now()
+      try {
+        const response = await fetch('/api/public/version', { cache: 'no-store' })
+        if (!response.ok) return
+        const { version: running } = (await response.json()) as { version?: string }
+        if (running && running !== 'unknown' && running !== version) show()
+      } catch {
+        // Offline or mid-deploy: the next check will tell
+      }
+    }
+
+    const onVisible = () => {
+      if (document.visibilityState === 'visible' && Date.now() - lastCheck > FOCUS_MIN_GAP_MS) {
+        void check()
+      }
+    }
+    // A fallback for a Next version that stops sending the header
+    const onRejection = (event: PromiseRejectionEvent) => {
+      if (isOutdatedActionError(event.reason)) show()
+    }
+
+    // Next's router calls the global fetch for every server action, so this
+    // sees each answer. It only looks at the headers; the response is handed
+    // back untouched.
+    const originalFetch = window.fetch
+    const watchedFetch: typeof window.fetch = async (input, init) => {
+      const response = await originalFetch.call(window, input, init)
+      if (response.headers.get(ACTION_NOT_FOUND_HEADER) === '1') show()
+      return response
+    }
+    window.fetch = watchedFetch
+
+    const timer = window.setInterval(check, POLL_MS)
+    document.addEventListener('visibilitychange', onVisible)
+    window.addEventListener('focus', onVisible)
+    window.addEventListener('unhandledrejection', onRejection)
+    return () => {
+      // Put fetch back only if nothing else replaced it in the meantime
+      if (window.fetch === watchedFetch) window.fetch = originalFetch
+      window.clearInterval(timer)
+      document.removeEventListener('visibilitychange', onVisible)
+      window.removeEventListener('focus', onVisible)
+      window.removeEventListener('unhandledrejection', onRejection)
+    }
+  }, [version, t])
+
+  return null
+}

+ 29 - 0
src/lib/build-version.server.ts

@@ -0,0 +1,29 @@
+import 'server-only'
+import { readFileSync } from 'node:fs'
+import path from 'node:path'
+
+let cached: string | undefined
+
+/**
+ * The id of the build this server runs, the same for every request until the
+ * next deploy. A page carries the id it was rendered with; when the server
+ * answers with another one, the tab is running old code and is offered a
+ * reload. The dev server has no stable id, so it never asks.
+ *
+ * Read from `.next/BUILD_ID`, which the standalone output keeps next to
+ * server.js. APP_VERSION is not used: it is the image tag, and a redeploy of
+ * the same tag still ships new code.
+ */
+export function buildVersion(): string {
+  if (cached !== undefined) return cached
+  if (process.env.NODE_ENV !== 'production') {
+    cached = 'development'
+    return cached
+  }
+  try {
+    cached = readFileSync(path.join(process.cwd(), '.next', 'BUILD_ID'), 'utf8').trim()
+  } catch {
+    cached = 'unknown'
+  }
+  return cached
+}

+ 6 - 0
src/lib/request-path.ts

@@ -0,0 +1,6 @@
+/**
+ * The header proxy.ts sets on page requests: the path and query the browser
+ * asked for. Whatever a client sends under this name is overwritten there, and
+ * readers still pass it through safeRedirectPath.
+ */
+export const REQUEST_PATH_HEADER = 'x-torqvoice-path'

+ 16 - 0
src/lib/sign-in-redirect.server.ts

@@ -0,0 +1,16 @@
+import 'server-only'
+import { headers } from 'next/headers'
+import { redirect } from 'next/navigation'
+import { REQUEST_PATH_HEADER } from './request-path'
+import { signInUrlFor } from './signed-out'
+
+/**
+ * Sends a visitor without a session to sign-in, set to bring them back to the
+ * page they asked for. A layout cannot see its own URL, so the path comes from
+ * the header proxy.ts writes on every page request. Without the header (a
+ * request the proxy did not match) it is the plain sign-in page, as before.
+ */
+export async function redirectToSignIn(): Promise<never> {
+  const requested = (await headers()).get(REQUEST_PATH_HEADER)
+  redirect(requested ? signInUrlFor(requested) : '/auth/sign-in')
+}

+ 20 - 0
src/lib/signed-out.ts

@@ -0,0 +1,20 @@
+import { safeRedirectPath } from './safe-redirect'
+
+/**
+ * How long after the last keystroke a tab that has lost its session still
+ * waits for the person instead of leaving for the sign-in page. Someone typing
+ * a note would lose it to a page change, so they are told and choose; an idle
+ * tab goes straight to sign-in.
+ */
+export const RECENT_INPUT_MS = 60 * 1000
+
+/** The sign-in page, set to come back to the page this tab is on. */
+export function signInUrlFor(pathname: string, search = ''): string {
+  const target = safeRedirectPath(`${pathname}${search}`)
+  return target === '/' ? '/auth/sign-in' : `/auth/sign-in?redirect=${encodeURIComponent(target)}`
+}
+
+/** Whether a tab that has lost its session should leave for sign-in right away. */
+export function leaveForSignInNow(lastInputAt: number, now: number): boolean {
+  return now - lastInputAt > RECENT_INPUT_MS
+}

+ 20 - 0
src/proxy.ts

@@ -1,6 +1,7 @@
 import { NextResponse } from 'next/server'
 import type { NextRequest } from 'next/server'
 import { isCrossSiteWrite } from '@/lib/same-origin'
+import { REQUEST_PATH_HEADER } from '@/lib/request-path'
 
 /**
  * Cross-origin access to the technician API, in development only.
@@ -45,7 +46,23 @@ function applyCors(response: NextResponse, origin: string | null) {
 
 const PROTECTED_API = '/api/protected/'
 
+/**
+ * A page request carries the path it asked for, so a layout that sends a
+ * visitor to sign-in can bring them back there (lib/sign-in-redirect.server.ts).
+ * A layout has no other way to see its URL. Next's own `_rsc` marker is left
+ * out, and anything a client sent under the same name is overwritten.
+ */
+function withRequestPath(request: NextRequest) {
+  const url = request.nextUrl.clone()
+  url.searchParams.delete('_rsc')
+  const requestHeaders = new Headers(request.headers)
+  requestHeaders.set(REQUEST_PATH_HEADER, `${url.pathname}${url.search}`)
+  return NextResponse.next({ request: { headers: requestHeaders } })
+}
+
 export default function proxy(request: NextRequest) {
+  if (!request.nextUrl.pathname.startsWith('/api/')) return withRequestPath(request)
+
   // A write to the cookie-authenticated API from another site is refused
   // before any handler runs. See lib/same-origin.ts.
   if (request.nextUrl.pathname.startsWith(PROTECTED_API)) {
@@ -79,5 +96,8 @@ export const config = {
     '/api/public/auth/:path*',
     // The cookie-authenticated API: cross-site writes are refused here.
     '/api/protected/:path*',
+    // Pages, for the requested path above. Not the API, Next's own files, or
+    // anything with a file extension (icons, images, the manifest).
+    '/((?!api/|_next/|.*\\.[^/]+$).*)',
   ],
 }