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

Signed-in devices, new-device mail, and password changes that end other sessions (#389)

* Signed-in devices, a mail on a new one, and password changes that end the other sessions

A password reset or change now ends every other session on the account. The account
page lists every open session as a device (phone, tablet, computer or the technician
app) with a sign-out for each and one for all the others, under a new Account item in
the sidebar's user menu. A sign-in from a browser the account has not seen before mails
the address; the first device is recorded quietly. Devices are remembered in a new
user_devices table (migration 20260912152131_known_devices, not applied). Unit tests for
the device labels; end-to-end coverage in auth/devices, with the sign-in and account
specs re-saving the shared session they now rotate.

* Issue the device cookie at the auth route, and drop the session cookie cache

The session hook runs after the response is assembled, so a cookie set from it never
reached the browser and every sign-in looked like a new device. The auth route now mints
the id before better-auth runs and sets it on the response. The session cookie cache is
off: a session signed out from the devices list, or ended by a password change, is refused
on its next request instead of five minutes later. A device seen while another session is
open counts as new, so existing accounts get the mail from the first foreign sign-in.

* Write the account mails like ordinary mail

Verification, password reset, new-address confirmation and new-device mails share one
plain layout now: left-aligned text, a greeting, a few sentences, one ordinary link shown
with its address, a sign-off, and a text half that says the same. No centred box, no
button. Rendered by src/lib/account-mail.ts, with a unit test.
Bernt Christian Egeland 2 недель назад
Родитель
Сommit
81b8dbb45e
56 измененных файлов с 1259 добавлено и 82 удалено
  1. 19 2
      e2e/specs/auth/account.spec.ts
  2. 139 0
      e2e/specs/auth/devices.spec.ts
  3. 12 0
      e2e/specs/auth/sign-in.spec.ts
  4. 24 0
      e2e/support/db.ts
  5. 2 0
      messages/de/audit.json
  6. 1 0
      messages/de/navigation.json
  7. 10 0
      messages/de/settings.json
  8. 2 0
      messages/en/audit.json
  9. 1 0
      messages/en/navigation.json
  10. 10 0
      messages/en/settings.json
  11. 2 0
      messages/es/audit.json
  12. 1 0
      messages/es/navigation.json
  13. 10 0
      messages/es/settings.json
  14. 2 0
      messages/fr/audit.json
  15. 1 0
      messages/fr/navigation.json
  16. 10 0
      messages/fr/settings.json
  17. 2 0
      messages/it/audit.json
  18. 1 0
      messages/it/navigation.json
  19. 10 0
      messages/it/settings.json
  20. 2 0
      messages/lt/audit.json
  21. 1 0
      messages/lt/navigation.json
  22. 10 0
      messages/lt/settings.json
  23. 2 0
      messages/nb/audit.json
  24. 1 0
      messages/nb/navigation.json
  25. 10 0
      messages/nb/settings.json
  26. 2 0
      messages/nl/audit.json
  27. 1 0
      messages/nl/navigation.json
  28. 10 0
      messages/nl/settings.json
  29. 2 0
      messages/pl/audit.json
  30. 1 0
      messages/pl/navigation.json
  31. 10 0
      messages/pl/settings.json
  32. 2 0
      messages/pt-BR/audit.json
  33. 1 0
      messages/pt-BR/navigation.json
  34. 10 0
      messages/pt-BR/settings.json
  35. 2 0
      messages/ru/audit.json
  36. 1 0
      messages/ru/navigation.json
  37. 10 0
      messages/ru/settings.json
  38. 2 0
      messages/tr/audit.json
  39. 1 0
      messages/tr/navigation.json
  40. 10 0
      messages/tr/settings.json
  41. 21 0
      prisma/migrations/20260912152131_known_devices/migration.sql
  42. 26 0
      prisma/schema/auth.prisma
  43. 56 0
      src/__tests__/lib/account-mail.test.ts
  44. 52 0
      src/__tests__/lib/device-cookie.test.ts
  45. 73 0
      src/__tests__/lib/known-devices.test.ts
  46. 9 0
      src/app/(authenticated)/settings/account/account-settings.tsx
  47. 4 1
      src/app/(authenticated)/settings/account/page.tsx
  48. 169 0
      src/app/(authenticated)/settings/account/signed-in-devices.tsx
  49. 17 4
      src/app/api/public/auth/[...all]/route.ts
  50. 8 3
      src/components/app-sidebar.tsx
  51. 11 23
      src/features/settings/Actions/accountActions.ts
  52. 86 0
      src/features/settings/Actions/sessionActions.ts
  53. 75 0
      src/lib/account-mail.ts
  54. 63 49
      src/lib/auth.ts
  55. 55 0
      src/lib/device-cookie.ts
  56. 184 0
      src/lib/known-devices.ts

+ 19 - 2
e2e/specs/auth/account.spec.ts

@@ -40,7 +40,24 @@ async function changePassword(page: Page, from: string, to: string) {
 }
 
 test.describe('password', () => {
-  test('is changed from account settings and works at the door', async ({ page, browser }) => {
+  // One context for both steps, and its session saved afterwards: changing
+  // the password ends every session on the account, the caller's included,
+  // and hands this context a new one. A fresh context from the saved state
+  // would come back with the token the change deleted.
+  let owner: BrowserContext
+  let page: Page
+
+  test.beforeAll(async ({ browser }) => {
+    owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
+    page = await owner.newPage()
+  })
+
+  test.afterAll(async () => {
+    await owner.storageState({ path: 'e2e/.auth/owner.json' })
+    await owner.close()
+  })
+
+  test('is changed from account settings and works at the door', async ({ browser }) => {
     await changePassword(page, password, changed)
 
     const fresh = await signedOut(browser)
@@ -49,7 +66,7 @@ test.describe('password', () => {
     await fresh.context().close()
   })
 
-  test('is put back for the rest of the suite', async ({ page }) => {
+  test('is put back for the rest of the suite', async () => {
     await changePassword(page, changed, password)
   })
 })

+ 139 - 0
e2e/specs/auth/devices.spec.ts

@@ -0,0 +1,139 @@
+import { type BrowserContext, expect, type Page, test } from '@playwright/test'
+import { deviceCountFor, sessionCountFor } from '../../support/db'
+import { fillSettled } from '../../support/hydration'
+import { mailsTo, waitForMail } from '../../support/mail'
+
+/**
+ * Who is signed in to an account, and how to get them out.
+ *
+ * A sign-in from a browser the account has not seen before earns a mail,
+ * the account page lists every open session as a device the owner can
+ * recognise, any of them can be signed out from there, and a password change
+ * ends all the others by itself. That last one is what makes a password
+ * change worth anything against a stolen session.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const email = process.env.E2E_USER_EMAIL ?? 'demo@torqvoice.com'
+const password = process.env.E2E_USER_PASSWORD ?? 'demo-e2e-pass'
+const stamp = Date.now()
+const changed = `E2e-devices-${stamp}`
+
+/** A phone: no cookies from anywhere, and a user agent the list will name. */
+const PHONE_UA =
+  'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'
+
+async function signIn(page: Page, secret = password) {
+  await page.goto('/auth/sign-in')
+  await page.locator('#email').fill(email)
+  await page.locator('#password').fill(secret)
+  await page.getByRole('button', { name: 'Sign In', exact: true }).click()
+  await page.waitForURL((url) => !url.pathname.startsWith('/auth'), { timeout: 30_000 })
+}
+
+/** Whether a context's session is still honoured, asked past the cookie cache. */
+async function stillSignedIn(context: BrowserContext): Promise<boolean> {
+  const response = await context.request.get('/api/public/auth/get-session?disableCookieCache=true')
+  const body = await response.text()
+  return body !== 'null' && body !== ''
+}
+
+let phone: BrowserContext
+
+test.beforeAll(async ({ browser }) => {
+  phone = await browser.newContext({
+    storageState: { cookies: [], origins: [] },
+    userAgent: PHONE_UA,
+  })
+})
+
+test.afterAll(async () => {
+  await phone.close()
+})
+
+test('a sign-in from a new browser mails the owner', async () => {
+  const before = Date.now()
+  const page = await phone.newPage()
+  await signIn(page)
+
+  const mail = await waitForMail(email, { subject: /new sign-in/i })
+  expect(mail.html).toContain('Safari on iPhone')
+  expect(mail.html).toContain('/settings/account')
+  expect(new Date(mail.receivedAt).getTime()).toBeGreaterThanOrEqual(before - 60_000)
+})
+
+test('the account page lists the phone and signs it out', async ({ page }) => {
+  await page.goto('/settings/account')
+  const list = page.getByTestId('signed-in-devices')
+  const own = list.getByTestId('signed-in-device').filter({ hasText: 'This device' })
+  await expect(own).toHaveCount(1)
+  const iphone = list.getByTestId('signed-in-device').filter({ hasText: 'Safari on iPhone' })
+  await expect(iphone.first()).toBeVisible()
+
+  const sessionsBefore = await sessionCountFor(email)
+  await iphone
+    .first()
+    .getByRole('button', { name: /sign out/i })
+    .click()
+  await expect(page.getByText('Device signed out', { exact: true })).toBeVisible()
+  await expect(
+    list.getByTestId('signed-in-device').filter({ hasText: 'Safari on iPhone' })
+  ).toHaveCount(0)
+
+  expect(await sessionCountFor(email)).toBe(sessionsBefore - 1)
+  expect(await stillSignedIn(phone), 'the phone is out').toBe(false)
+
+  // What the person holding the phone sees: the next page it asks for is the
+  // sign-in page. A row count proved nothing here while the session cookie
+  // cache let a revoked session keep working for five minutes.
+  const held = await phone.newPage()
+  await held.goto('/customers')
+  await expect(held).toHaveURL(/\/auth\/sign-in/, { timeout: 15_000 })
+  await held.close()
+})
+
+test('signing in again on the same phone is not a new device', async () => {
+  // The device cookie outlives the session, so the phone that was just
+  // signed out comes back as itself: one device row, no second mail.
+  const mailsBefore = (await mailsTo(email)).filter((m) => /new sign-in/i.test(m.subject)).length
+  const rowsBefore = await deviceCountFor(email, 'iPhone')
+  const page = await phone.newPage()
+  await signIn(page)
+  await page.waitForTimeout(1_500)
+  const mailsAfter = (await mailsTo(email)).filter((m) => /new sign-in/i.test(m.subject)).length
+  expect(mailsAfter, 'no new-device mail for a device the account knows').toBe(mailsBefore)
+  expect(await deviceCountFor(email, 'iPhone'), 'no second row for the phone').toBe(rowsBefore)
+  await page.close()
+})
+
+test('changing the password ends every other device', async ({ page, browser }) => {
+  const other = await browser.newContext({ storageState: { cookies: [], origins: [] } })
+  await signIn(await other.newPage())
+  expect(await stillSignedIn(other)).toBe(true)
+
+  await page.goto('/settings/account')
+  await fillSettled(page.locator('#currentPassword'), password)
+  await fillSettled(page.locator('#newPassword'), changed)
+  await fillSettled(page.locator('#confirmPassword'), changed)
+  await page.getByRole('button', { name: 'Change Password', exact: true }).click()
+  await expect(page.getByText('Password changed', { exact: true })).toBeVisible()
+
+  expect(await stillSignedIn(other), 'the other browser is out').toBe(false)
+  expect(await sessionCountFor(email), 'only the changing browser remains').toBe(1)
+  const held = await other.newPage()
+  await held.goto('/customers')
+  await expect(held, 'the other browser lands on sign-in').toHaveURL(/\/auth\/sign-in/, {
+    timeout: 15_000,
+  })
+  await other.close()
+
+  // Put the seeded password back for the rest of the suite, and save the
+  // session this browser ended up with: each change retired the one before.
+  await fillSettled(page.locator('#currentPassword'), changed)
+  await fillSettled(page.locator('#newPassword'), password)
+  await fillSettled(page.locator('#confirmPassword'), password)
+  await page.getByRole('button', { name: 'Change Password', exact: true }).click()
+  await expect(page.getByText('Password changed', { exact: true }).first()).toBeVisible()
+  await page.context().storageState({ path: 'e2e/.auth/owner.json' })
+})

+ 12 - 0
e2e/specs/auth/sign-in.spec.ts

@@ -119,6 +119,18 @@ test.describe('forgotten password', () => {
   // spend it twice. better-auth deletes it on use, which is the point.
   let spent = ''
 
+  // A reset ends every session the owner has, the shared one from setup
+  // included; that is the protection. The rest of the suite still signs in
+  // with the saved state, so a fresh session is saved over it afterwards.
+  test.afterAll(async ({ browser }) => {
+    const context = await browser.newContext({ storageState: { cookies: [], origins: [] } })
+    const page = await context.newPage()
+    await signIn(page, email, password)
+    await expectSignedIn(page)
+    await context.storageState({ path: 'e2e/.auth/owner.json' })
+    await context.close()
+  })
+
   test('a made-up token cannot set a password', async ({ page }) => {
     await resetWith(page, 'not-a-real-token', replacement)
     // better-auth's own words, or the page's fallback when it has none.

+ 24 - 0
e2e/support/db.ts

@@ -1069,3 +1069,27 @@ export async function deleteInboundWhatsapp(organizationId: string, body: string
     )
   )
 }
+
+/** Open sessions a person has, however many browsers and phones that is. */
+export async function sessionCountFor(email: string): Promise<number> {
+  return withDb(async (db) => {
+    const result = await db.query<{ n: string }>(
+      `select count(*)::text as n from sessions s join users u on u.id = s."userId"
+        where lower(u.email) = lower($1) and s."expiresAt" > now()`,
+      [email]
+    )
+    return Number(result.rows[0].n)
+  })
+}
+
+/** Device rows a person has whose user agent mentions `needle`. */
+export async function deviceCountFor(email: string, needle: string): Promise<number> {
+  return withDb(async (db) => {
+    const result = await db.query<{ n: string }>(
+      `select count(*)::text as n from user_devices d join users u on u.id = d."userId"
+        where lower(u.email) = lower($1) and d."userAgent" like $2`,
+      [email, `%${needle}%`]
+    )
+    return Number(result.rows[0].n)
+  })
+}

+ 2 - 0
messages/de/audit.json

@@ -85,6 +85,8 @@
     "customField_delete": "Benutzerdefiniertes Feld gelöscht",
     "quoteRequest_update": "Angebotsanfrage aktualisiert",
     "auth_login": "Benutzer angemeldet",
+    "auth_sessionRevoked": "Gerät abgemeldet",
+    "auth_sessionsRevoked": "Andere Geräte abgemeldet",
     "auth_loginFailed": "Fehlgeschlagener Anmeldeversuch",
     "auth_register": "Benutzer registriert",
     "auth_passwordReset": "Passwort-Zurücksetzung angefordert",

+ 1 - 0
messages/de/navigation.json

@@ -90,6 +90,7 @@
   "licenseUnverifiedNow": "Ihre Lizenz konnte nicht überprüft werden und das Torqvoice-Branding ist zurückgekehrt.",
   "licenseVerify": "Überprüfen",
   "sidebar": {
+    "account": "Konto",
     "dashboard": "Dashboard",
     "clients": "Kunden",
     "customers": "Kunden",

+ 10 - 0
messages/de/settings.json

@@ -196,6 +196,16 @@
     "marineServiceDescription": "Boote, Yachten und Wasserfahrzeuge"
   },
   "account": {
+    "devicesTitle": "Angemeldete Geräte",
+    "devicesDescription": "Jeder Browser und jedes Telefon mit einer offenen Sitzung auf deinem Konto.",
+    "thisDevice": "Dieses Gerät",
+    "signedInAt": "Angemeldet {date}",
+    "lastActiveAt": "Zuletzt aktiv {date}",
+    "signOutDevice": "Abmelden",
+    "signOutOthers": "Andere Geräte abmelden",
+    "deviceSignedOut": "Gerät abgemeldet",
+    "othersSignedOut": "Andere Geräte abgemeldet",
+    "failedSignOutDevice": "Das Gerät konnte nicht abgemeldet werden",
     "profileTitle": "Profil",
     "profileDescription": "Aktualisieren Sie Ihren Anzeigenamen und Ihre E-Mail-Adresse.",
     "emailVerified": "Bestätigt",

+ 2 - 0
messages/en/audit.json

@@ -85,6 +85,8 @@
     "customField_delete": "Deleted Custom Field",
     "quoteRequest_update": "Updated Quote Request",
     "auth_login": "User Logged In",
+    "auth_sessionRevoked": "Signed Out a Device",
+    "auth_sessionsRevoked": "Signed Out Other Devices",
     "auth_loginFailed": "Failed Login Attempt",
     "auth_register": "User Registered",
     "auth_passwordReset": "Password Reset Requested",

+ 1 - 0
messages/en/navigation.json

@@ -90,6 +90,7 @@
   "licenseUnverifiedNow": "Your license could not be verified and Torqvoice branding has returned.",
   "licenseVerify": "Verify",
   "sidebar": {
+    "account": "Account",
     "dashboard": "Dashboard",
     "clients": "Clients",
     "customers": "Customers",

+ 10 - 0
messages/en/settings.json

@@ -196,6 +196,16 @@
     "marineServiceDescription": "Boats, yachts, and marine vessels"
   },
   "account": {
+    "devicesTitle": "Signed-in devices",
+    "devicesDescription": "Every browser and phone with an open session on your account.",
+    "thisDevice": "This device",
+    "signedInAt": "Signed in {date}",
+    "lastActiveAt": "Last active {date}",
+    "signOutDevice": "Sign out",
+    "signOutOthers": "Sign out other devices",
+    "deviceSignedOut": "Device signed out",
+    "othersSignedOut": "Other devices signed out",
+    "failedSignOutDevice": "Could not sign out that device",
     "profileTitle": "Profile",
     "profileDescription": "Update your display name and email address.",
     "emailVerified": "Verified",

+ 2 - 0
messages/es/audit.json

@@ -85,6 +85,8 @@
     "customField_delete": "Campo personalizado eliminado",
     "quoteRequest_update": "Solicitud de presupuesto actualizada",
     "auth_login": "Usuario conectado",
+    "auth_sessionRevoked": "Cerró sesión en un dispositivo",
+    "auth_sessionsRevoked": "Cerró sesión en otros dispositivos",
     "auth_loginFailed": "Intento de inicio de sesión fallido",
     "auth_register": "Usuario registrado",
     "auth_passwordReset": "Restablecimiento de contraseña solicitado",

+ 1 - 0
messages/es/navigation.json

@@ -90,6 +90,7 @@
   "licenseUnverifiedNow": "No se pudo verificar su licencia y la marca Torqvoice ha vuelto.",
   "licenseVerify": "Verificar",
   "sidebar": {
+    "account": "Cuenta",
     "dashboard": "Panel de control",
     "clients": "Clientes",
     "customers": "Clientes",

+ 10 - 0
messages/es/settings.json

@@ -196,6 +196,16 @@
     "marineServiceDescription": "Barcos, yates y embarcaciones"
   },
   "account": {
+    "devicesTitle": "Dispositivos con sesión iniciada",
+    "devicesDescription": "Todos los navegadores y teléfonos con una sesión abierta en tu cuenta.",
+    "thisDevice": "Este dispositivo",
+    "signedInAt": "Sesión iniciada {date}",
+    "lastActiveAt": "Última actividad {date}",
+    "signOutDevice": "Cerrar sesión",
+    "signOutOthers": "Cerrar sesión en otros dispositivos",
+    "deviceSignedOut": "Sesión cerrada en el dispositivo",
+    "othersSignedOut": "Sesión cerrada en los demás dispositivos",
+    "failedSignOutDevice": "No se pudo cerrar la sesión de ese dispositivo",
     "profileTitle": "Perfil",
     "profileDescription": "Actualice su nombre para mostrar y dirección de correo electrónico.",
     "emailVerified": "Verificado",

+ 2 - 0
messages/fr/audit.json

@@ -85,6 +85,8 @@
     "customField_delete": "Champ personnalisé supprimé",
     "quoteRequest_update": "Demande de devis mise à jour",
     "auth_login": "Utilisateur connecté",
+    "auth_sessionRevoked": "Appareil déconnecté",
+    "auth_sessionsRevoked": "Autres appareils déconnectés",
     "auth_loginFailed": "Tentative de connexion échouée",
     "auth_register": "Utilisateur inscrit",
     "auth_passwordReset": "Réinitialisation du mot de passe demandée",

+ 1 - 0
messages/fr/navigation.json

@@ -90,6 +90,7 @@
   "licenseUnverifiedNow": "Votre licence n'a pas pu être vérifiée et la marque Torqvoice est réapparue.",
   "licenseVerify": "Vérifier",
   "sidebar": {
+    "account": "Compte",
     "dashboard": "Tableau de bord",
     "clients": "Clients",
     "customers": "Clients",

+ 10 - 0
messages/fr/settings.json

@@ -196,6 +196,16 @@
     "marineServiceDescription": "Bateaux, yachts et navires"
   },
   "account": {
+    "devicesTitle": "Appareils connectés",
+    "devicesDescription": "Tous les navigateurs et téléphones ayant une session ouverte sur votre compte.",
+    "thisDevice": "Cet appareil",
+    "signedInAt": "Connecté le {date}",
+    "lastActiveAt": "Dernière activité {date}",
+    "signOutDevice": "Déconnecter",
+    "signOutOthers": "Déconnecter les autres appareils",
+    "deviceSignedOut": "Appareil déconnecté",
+    "othersSignedOut": "Autres appareils déconnectés",
+    "failedSignOutDevice": "Impossible de déconnecter cet appareil",
     "profileTitle": "Profil",
     "profileDescription": "Mettez a jour votre nom d'affichage et votre adresse e-mail.",
     "emailVerified": "Vérifié",

+ 2 - 0
messages/it/audit.json

@@ -85,6 +85,8 @@
     "customField_delete": "Campo personalizzato eliminato",
     "quoteRequest_update": "Richiesta di preventivo aggiornata",
     "auth_login": "Utente connesso",
+    "auth_sessionRevoked": "Dispositivo disconnesso",
+    "auth_sessionsRevoked": "Altri dispositivi disconnessi",
     "auth_loginFailed": "Tentativo di accesso fallito",
     "auth_register": "Utente registrato",
     "auth_passwordReset": "Reimpostazione password richiesta",

+ 1 - 0
messages/it/navigation.json

@@ -90,6 +90,7 @@
   "licenseUnverifiedNow": "Non è stato possibile verificare la licenza e il marchio Torqvoice è tornato.",
   "licenseVerify": "Verifica",
   "sidebar": {
+    "account": "Account",
     "dashboard": "Dashboard",
     "clients": "Clienti",
     "customers": "Clienti",

+ 10 - 0
messages/it/settings.json

@@ -196,6 +196,16 @@
     "marineServiceDescription": "Barche, yacht e imbarcazioni"
   },
   "account": {
+    "devicesTitle": "Dispositivi connessi",
+    "devicesDescription": "Tutti i browser e i telefoni con una sessione aperta sul tuo account.",
+    "thisDevice": "Questo dispositivo",
+    "signedInAt": "Accesso effettuato {date}",
+    "lastActiveAt": "Ultima attività {date}",
+    "signOutDevice": "Disconnetti",
+    "signOutOthers": "Disconnetti gli altri dispositivi",
+    "deviceSignedOut": "Dispositivo disconnesso",
+    "othersSignedOut": "Altri dispositivi disconnessi",
+    "failedSignOutDevice": "Impossibile disconnettere quel dispositivo",
     "profileTitle": "Profilo",
     "profileDescription": "Aggiorna il tuo nome visualizzato e indirizzo email.",
     "emailVerified": "Verificato",

+ 2 - 0
messages/lt/audit.json

@@ -85,6 +85,8 @@
     "customField_delete": "Ištrintas pasirinktinis laukas",
     "quoteRequest_update": "Atnaujinta pasiūlymo užklausa",
     "auth_login": "Naudotojas prisijungė",
+    "auth_sessionRevoked": "Atjungtas įrenginys",
+    "auth_sessionsRevoked": "Atjungti kiti įrenginiai",
     "auth_loginFailed": "Nesėkmingas prisijungimo bandymas",
     "auth_register": "Naudotojas užsiregistravo",
     "auth_passwordReset": "Prašomas slaptažodžio atstatymas",

+ 1 - 0
messages/lt/navigation.json

@@ -90,6 +90,7 @@
   "licenseUnverifiedNow": "Nepavyko patikrinti jūsų licencijos ir Torqvoice prekės ženklas grįžo.",
   "licenseVerify": "Patikrinti",
   "sidebar": {
+    "account": "Paskyra",
     "dashboard": "Valdymo skydelis",
     "clients": "Klientai",
     "customers": "Klientai",

+ 10 - 0
messages/lt/settings.json

@@ -196,6 +196,16 @@
     "marineServiceDescription": "Valtys, jachtos ir jūrinės transporto priemonės"
   },
   "account": {
+    "devicesTitle": "Prisijungę įrenginiai",
+    "devicesDescription": "Visos naršyklės ir telefonai su atvira sesija jūsų paskyroje.",
+    "thisDevice": "Šis įrenginys",
+    "signedInAt": "Prisijungta {date}",
+    "lastActiveAt": "Paskutinį kartą aktyvus {date}",
+    "signOutDevice": "Atsijungti",
+    "signOutOthers": "Atjungti kitus įrenginius",
+    "deviceSignedOut": "Įrenginys atjungtas",
+    "othersSignedOut": "Kiti įrenginiai atjungti",
+    "failedSignOutDevice": "Nepavyko atjungti to įrenginio",
     "profileTitle": "Profilis",
     "profileDescription": "Atnaujinkite savo rodomą vardą ir el. pašto adresą.",
     "emailVerified": "Patvirtintas",

+ 2 - 0
messages/nb/audit.json

@@ -85,6 +85,8 @@
     "customField_delete": "Slettet egendefinert felt",
     "quoteRequest_update": "Oppdaterte tilbudsforespørsel",
     "auth_login": "Bruker logget inn",
+    "auth_sessionRevoked": "Logget ut en enhet",
+    "auth_sessionsRevoked": "Logget ut andre enheter",
     "auth_loginFailed": "Mislykket påloggingsforsøk",
     "auth_register": "Bruker registrert",
     "auth_passwordReset": "Passord-tilbakestilling forespurt",

+ 1 - 0
messages/nb/navigation.json

@@ -90,6 +90,7 @@
   "licenseUnverifiedNow": "Lisensen din kunne ikke bekreftes, og Torqvoice-merkingen er tilbake.",
   "licenseVerify": "Bekreft",
   "sidebar": {
+    "account": "Konto",
     "dashboard": "Dashbord",
     "clients": "Klienter",
     "customers": "Kunder",

+ 10 - 0
messages/nb/settings.json

@@ -196,6 +196,16 @@
     "marineServiceDescription": "Båter, yachter og marine fartøy"
   },
   "account": {
+    "devicesTitle": "Innloggede enheter",
+    "devicesDescription": "Alle nettlesere og telefoner med en åpen økt på kontoen din.",
+    "thisDevice": "Denne enheten",
+    "signedInAt": "Logget inn {date}",
+    "lastActiveAt": "Sist aktiv {date}",
+    "signOutDevice": "Logg ut",
+    "signOutOthers": "Logg ut andre enheter",
+    "deviceSignedOut": "Enheten er logget ut",
+    "othersSignedOut": "Andre enheter er logget ut",
+    "failedSignOutDevice": "Kunne ikke logge ut enheten",
     "profileTitle": "Profil",
     "profileDescription": "Oppdater visningsnavnet og e-postadressen din.",
     "emailVerified": "Bekreftet",

+ 2 - 0
messages/nl/audit.json

@@ -85,6 +85,8 @@
     "customField_delete": "Aangepast veld verwijderd",
     "quoteRequest_update": "Offerteaanvraag bijgewerkt",
     "auth_login": "Gebruiker ingelogd",
+    "auth_sessionRevoked": "Apparaat uitgelogd",
+    "auth_sessionsRevoked": "Andere apparaten uitgelogd",
     "auth_loginFailed": "Mislukte inlogpoging",
     "auth_register": "Gebruiker geregistreerd",
     "auth_passwordReset": "Wachtwoord reset aangevraagd",

+ 1 - 0
messages/nl/navigation.json

@@ -90,6 +90,7 @@
   "licenseUnverifiedNow": "Uw licentie kon niet worden geverifieerd en de Torqvoice-branding is teruggekeerd.",
   "licenseVerify": "Verifiëren",
   "sidebar": {
+    "account": "Account",
     "dashboard": "Dashboard",
     "clients": "Klanten",
     "customers": "Klanten",

+ 10 - 0
messages/nl/settings.json

@@ -196,6 +196,16 @@
     "marineServiceDescription": "Boten, jachten en vaartuigen"
   },
   "account": {
+    "devicesTitle": "Ingelogde apparaten",
+    "devicesDescription": "Elke browser en telefoon met een open sessie op je account.",
+    "thisDevice": "Dit apparaat",
+    "signedInAt": "Ingelogd {date}",
+    "lastActiveAt": "Laatst actief {date}",
+    "signOutDevice": "Uitloggen",
+    "signOutOthers": "Andere apparaten uitloggen",
+    "deviceSignedOut": "Apparaat uitgelogd",
+    "othersSignedOut": "Andere apparaten uitgelogd",
+    "failedSignOutDevice": "Kon dat apparaat niet uitloggen",
     "profileTitle": "Profiel",
     "profileDescription": "Werk uw weergavenaam en e-mailadres bij.",
     "emailVerified": "Geverifieerd",

+ 2 - 0
messages/pl/audit.json

@@ -85,6 +85,8 @@
     "customField_delete": "Usunięto pole niestandardowe",
     "quoteRequest_update": "Zaktualizowano zapytanie o wycenę",
     "auth_login": "Użytkownik zalogowany",
+    "auth_sessionRevoked": "Wylogowano urządzenie",
+    "auth_sessionsRevoked": "Wylogowano pozostałe urządzenia",
     "auth_loginFailed": "Nieudana próba logowania",
     "auth_register": "Użytkownik zarejestrowany",
     "auth_passwordReset": "Żądanie resetowania hasła",

+ 1 - 0
messages/pl/navigation.json

@@ -90,6 +90,7 @@
   "licenseUnverifiedNow": "Nie udało się zweryfikować licencji i oznaczenie Torqvoice wróciło.",
   "licenseVerify": "Zweryfikuj",
   "sidebar": {
+    "account": "Konto",
     "dashboard": "Pulpit",
     "clients": "Klienci",
     "customers": "Klienci",

+ 10 - 0
messages/pl/settings.json

@@ -196,6 +196,16 @@
     "marineServiceDescription": "Łodzie, jachty i jednostki pływające"
   },
   "account": {
+    "devicesTitle": "Zalogowane urządzenia",
+    "devicesDescription": "Każda przeglądarka i telefon z otwartą sesją na Twoim koncie.",
+    "thisDevice": "To urządzenie",
+    "signedInAt": "Zalogowano {date}",
+    "lastActiveAt": "Ostatnia aktywność {date}",
+    "signOutDevice": "Wyloguj",
+    "signOutOthers": "Wyloguj pozostałe urządzenia",
+    "deviceSignedOut": "Urządzenie wylogowane",
+    "othersSignedOut": "Pozostałe urządzenia wylogowane",
+    "failedSignOutDevice": "Nie udało się wylogować tego urządzenia",
     "profileTitle": "Profil",
     "profileDescription": "Zaktualizuj swoją nazwę wyświetlana i adres e-mail.",
     "emailVerified": "Zweryfikowany",

+ 2 - 0
messages/pt-BR/audit.json

@@ -85,6 +85,8 @@
     "customField_delete": "Campo personalizado excluído",
     "quoteRequest_update": "Solicitação de orçamento atualizada",
     "auth_login": "Usuário conectado",
+    "auth_sessionRevoked": "Desconectou um dispositivo",
+    "auth_sessionsRevoked": "Desconectou os outros dispositivos",
     "auth_loginFailed": "Tentativa de login falhada",
     "auth_register": "Usuário registrado",
     "auth_passwordReset": "Redefinição de senha solicitada",

+ 1 - 0
messages/pt-BR/navigation.json

@@ -90,6 +90,7 @@
   "licenseUnverifiedNow": "Não foi possível verificar sua licença e a marca Torqvoice voltou.",
   "licenseVerify": "Verificar",
   "sidebar": {
+    "account": "Conta",
     "dashboard": "Painel",
     "clients": "Clientes",
     "customers": "Clientes",

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

@@ -196,6 +196,16 @@
     "marineServiceDescription": "Barcos, iates e embarcações"
   },
   "account": {
+    "devicesTitle": "Dispositivos conectados",
+    "devicesDescription": "Todos os navegadores e telefones com uma sessão aberta na sua conta.",
+    "thisDevice": "Este dispositivo",
+    "signedInAt": "Conectado em {date}",
+    "lastActiveAt": "Última atividade {date}",
+    "signOutDevice": "Sair",
+    "signOutOthers": "Sair dos outros dispositivos",
+    "deviceSignedOut": "Dispositivo desconectado",
+    "othersSignedOut": "Outros dispositivos desconectados",
+    "failedSignOutDevice": "Não foi possível desconectar esse dispositivo",
     "profileTitle": "Perfil",
     "profileDescription": "Atualize seu nome de exibição e endereço de e-mail.",
     "emailVerified": "Verificado",

+ 2 - 0
messages/ru/audit.json

@@ -85,6 +85,8 @@
     "customField_delete": "Удалено пользовательское поле",
     "quoteRequest_update": "Обновлён запрос на предложение",
     "auth_login": "Пользователь вошёл в систему",
+    "auth_sessionRevoked": "Отключено устройство",
+    "auth_sessionsRevoked": "Отключены другие устройства",
     "auth_loginFailed": "Неудачная попытка входа",
     "auth_register": "Пользователь зарегистрировался",
     "auth_passwordReset": "Запрошен сброс пароля",

+ 1 - 0
messages/ru/navigation.json

@@ -90,6 +90,7 @@
   "licenseUnverifiedNow": "Не удалось проверить лицензию, и брендинг Torqvoice вернулся.",
   "licenseVerify": "Проверить",
   "sidebar": {
+    "account": "Аккаунт",
     "dashboard": "Панель управления",
     "clients": "Клиенты",
     "customers": "Клиенты",

+ 10 - 0
messages/ru/settings.json

@@ -196,6 +196,16 @@
     "marineServiceDescription": "Лодки, яхты и морские суда"
   },
   "account": {
+    "devicesTitle": "Устройства с входом",
+    "devicesDescription": "Все браузеры и телефоны с открытой сессией в вашем аккаунте.",
+    "thisDevice": "Это устройство",
+    "signedInAt": "Вход {date}",
+    "lastActiveAt": "Последняя активность {date}",
+    "signOutDevice": "Выйти",
+    "signOutOthers": "Выйти на других устройствах",
+    "deviceSignedOut": "Устройство отключено",
+    "othersSignedOut": "Другие устройства отключены",
+    "failedSignOutDevice": "Не удалось отключить это устройство",
     "profileTitle": "Профиль",
     "profileDescription": "Обновите своё отображаемое имя и адрес эл. почты.",
     "emailVerified": "Подтверждено",

+ 2 - 0
messages/tr/audit.json

@@ -85,6 +85,8 @@
     "customField_delete": "Özel alan silindi",
     "quoteRequest_update": "Teklif talebi güncellendi",
     "auth_login": "Kullanıcı giriş yaptı",
+    "auth_sessionRevoked": "Bir cihazın oturumu kapatıldı",
+    "auth_sessionsRevoked": "Diğer cihazların oturumu kapatıldı",
     "auth_loginFailed": "Başarısız giriş denemesi",
     "auth_register": "Kullanıcı kaydoldu",
     "auth_passwordReset": "Şifre sıfırlama talep edildi",

+ 1 - 0
messages/tr/navigation.json

@@ -90,6 +90,7 @@
   "licenseUnverifiedNow": "Lisansınız doğrulanamadı ve Torqvoice markası geri döndü.",
   "licenseVerify": "Doğrula",
   "sidebar": {
+    "account": "Hesap",
     "dashboard": "Kontrol Paneli",
     "clients": "Müşteriler",
     "customers": "Müşteriler",

+ 10 - 0
messages/tr/settings.json

@@ -196,6 +196,16 @@
     "marineServiceDescription": "Tekneler, yatlar ve deniz taşıtları"
   },
   "account": {
+    "devicesTitle": "Oturum açık cihazlar",
+    "devicesDescription": "Hesabınızda açık oturumu olan tüm tarayıcılar ve telefonlar.",
+    "thisDevice": "Bu cihaz",
+    "signedInAt": "Giriş {date}",
+    "lastActiveAt": "Son etkinlik {date}",
+    "signOutDevice": "Çıkış yap",
+    "signOutOthers": "Diğer cihazlardan çıkış yap",
+    "deviceSignedOut": "Cihaz oturumu kapatıldı",
+    "othersSignedOut": "Diğer cihazların oturumu kapatıldı",
+    "failedSignOutDevice": "Bu cihazın oturumu kapatılamadı",
     "profileTitle": "Profil",
     "profileDescription": "Görünen adınızı ve e-posta adresinizi güncelleyin.",
     "emailVerified": "Doğrulandı",

+ 21 - 0
prisma/migrations/20260912152131_known_devices/migration.sql

@@ -0,0 +1,21 @@
+-- CreateTable
+CREATE TABLE "user_devices" (
+    "id" TEXT NOT NULL,
+    "deviceKey" TEXT NOT NULL,
+    "userAgent" TEXT,
+    "lastIp" TEXT,
+    "firstSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "userId" TEXT NOT NULL,
+
+    CONSTRAINT "user_devices_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "user_devices_userId_idx" ON "user_devices"("userId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "user_devices_userId_deviceKey_key" ON "user_devices"("userId", "deviceKey");
+
+-- AddForeignKey
+ALTER TABLE "user_devices" ADD CONSTRAINT "user_devices_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 26 - 0
prisma/schema/auth.prisma

@@ -29,6 +29,7 @@ model User {
   dashboardLayout  Json?
 
   sessions               Session[]
+  devices                UserDevice[]
   accounts               Account[]
   pushDevices            PushDevice[]
   appSetupCodes          TechnicianSetupCode[]
@@ -137,3 +138,28 @@ model Passkey {
   @@index([userId])
   @@map("passkeys")
 }
+
+/// A browser or phone this person has signed in from before.
+///
+/// Sessions come and go (sign-out, expiry, a password change that ends the
+/// others), so the session table cannot say whether a device is new; this can.
+/// A browser is known by a random id in a long-lived cookie, set the first
+/// time it signs in; a client without cookies, such as the technician app,
+/// is known by its user agent. The first device an account ever sees is
+/// recorded quietly; every later one earns a "new sign-in" mail.
+model UserDevice {
+  id          String   @id @default(cuid())
+  /// Cookie id, or a hash of the user agent when no cookie can be kept.
+  deviceKey   String
+  userAgent   String?
+  lastIp      String?
+  firstSeenAt DateTime @default(now())
+  lastSeenAt  DateTime @default(now())
+
+  userId String
+  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+  @@unique([userId, deviceKey])
+  @@index([userId])
+  @@map("user_devices")
+}

+ 56 - 0
src/__tests__/lib/account-mail.test.ts

@@ -0,0 +1,56 @@
+import { describe, expect, it } from 'vitest'
+import { renderAccountMail } from '@/lib/account-mail'
+
+/**
+ * Account mails read like a mail from a person: left-aligned text, a
+ * greeting, one ordinary link, a sign-off, and a plain-text half that says
+ * the same thing. Nothing is centred and nothing is a button.
+ */
+describe('renderAccountMail', () => {
+  const mail = renderAccountMail({
+    to: 'a@b.test',
+    subject: 'Reset your password',
+    name: 'Kari <Nordmann>',
+    paragraphs: ['We received a request to reset your password.'],
+    link: { text: 'Reset your password', url: 'https://app.test/reset?token=abc&x=1' },
+    notes: ["If you didn't ask for this, ignore it."],
+  })
+
+  it('is plain and left-aligned, with no box or button', () => {
+    expect(mail.html).toContain('text-align: left')
+    expect(mail.html).not.toContain('margin: 0 auto')
+    expect(mail.html).not.toContain('max-width')
+    expect(mail.html).not.toContain('display: inline-block')
+    expect(mail.html).not.toContain('<h2')
+  })
+
+  it('escapes what people typed and shows the link address in full', () => {
+    expect(mail.html).toContain('Hi Kari &lt;Nordmann&gt;,')
+    expect(mail.html).not.toContain('<Nordmann>')
+    expect(mail.html).toContain('href="https://app.test/reset?token=abc&amp;x=1"')
+    expect(mail.html).toContain('>https://app.test/reset?token=abc&amp;x=1</a>')
+    expect(mail.html).toContain('If you didn&quot;t'.replace('&quot;', "'"))
+  })
+
+  it('carries the same words in the text half', () => {
+    expect(mail.text).toBe(
+      [
+        'Hi Kari <Nordmann>,',
+        '',
+        'We received a request to reset your password.',
+        '',
+        'Reset your password: https://app.test/reset?token=abc&x=1',
+        '',
+        "If you didn't ask for this, ignore it.",
+        '',
+        'Torqvoice',
+      ].join('\n')
+    )
+  })
+
+  it('greets without a name when there is none', () => {
+    const anonymous = renderAccountMail({ to: 'a@b.test', subject: 's', paragraphs: ['x'] })
+    expect(anonymous.text.startsWith('Hi,\n')).toBe(true)
+    expect(anonymous.html).not.toContain('undefined')
+  })
+})

+ 52 - 0
src/__tests__/lib/device-cookie.test.ts

@@ -0,0 +1,52 @@
+import { describe, expect, it } from 'vitest'
+import {
+  attachDeviceCookie,
+  DEVICE_COOKIE,
+  readDeviceCookie,
+  withDeviceCookie,
+} from '@/lib/device-cookie'
+
+/**
+ * The device cookie is issued at the auth route, before better-auth runs,
+ * because a cookie set from the session hook never reaches the browser. So
+ * the request the hook sees must already carry it, and the response must
+ * hand it out.
+ */
+describe('device cookie', () => {
+  it('mints one for a browser that has none, on the request and the response', () => {
+    const incoming = new Request('http://app.test/api/public/auth/sign-in/email', {
+      method: 'POST',
+      headers: { cookie: 'other=1' },
+    })
+    const { request, issued } = withDeviceCookie(incoming)
+    expect(issued).toMatch(/^[a-f0-9]{48}$/)
+    expect(readDeviceCookie(request.headers)).toBe(issued)
+    expect(request.headers.get('cookie')).toContain('other=1')
+
+    const response = attachDeviceCookie(
+      new Response('ok', { headers: { 'set-cookie': 'session=x' } }),
+      issued
+    )
+    const cookies = response.headers.getSetCookie()
+    expect(cookies).toHaveLength(2)
+    expect(cookies[1]).toContain(`${DEVICE_COOKIE}=${issued}`)
+    expect(cookies[1]).toContain('HttpOnly')
+    expect(cookies[1]).toContain('SameSite=Lax')
+  })
+
+  it('leaves a returning browser alone', () => {
+    const id = 'a'.repeat(48)
+    const incoming = new Request('http://app.test/x', {
+      headers: { cookie: `${DEVICE_COOKIE}=${id}` },
+    })
+    const { request, issued } = withDeviceCookie(incoming)
+    expect(issued).toBeNull()
+    expect(request).toBe(incoming)
+    expect(attachDeviceCookie(new Response('ok'), null).headers.getSetCookie()).toHaveLength(0)
+  })
+
+  it('ignores a cookie that is not one of ours', () => {
+    expect(readDeviceCookie(new Headers({ cookie: `${DEVICE_COOKIE}=not-hex` }))).toBeNull()
+    expect(readDeviceCookie(undefined)).toBeNull()
+  })
+})

+ 73 - 0
src/__tests__/lib/known-devices.test.ts

@@ -0,0 +1,73 @@
+import { describe, expect, it } from 'vitest'
+import { classifyUserAgent, describeUserAgent } from '@/lib/known-devices'
+
+/**
+ * The name a device gets in the "new sign-in" mail and the account page.
+ * Coarse on purpose: it has to be something the owner recognises as theirs,
+ * not something that identifies the device.
+ */
+describe('describeUserAgent', () => {
+  it('names the common browsers and systems', () => {
+    expect(
+      describeUserAgent(
+        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36'
+      )
+    ).toBe('Chrome on Windows')
+    expect(
+      describeUserAgent(
+        'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'
+      )
+    ).toBe('Safari on iPhone')
+    expect(
+      describeUserAgent(
+        'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0'
+      )
+    ).toBe('Edge on Mac')
+    expect(
+      describeUserAgent('Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0')
+    ).toBe('Firefox on Linux')
+    expect(
+      describeUserAgent(
+        'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36'
+      )
+    ).toBe('Chrome on Android')
+  })
+
+  it('names the technician app and gives up gracefully', () => {
+    expect(describeUserAgent('okhttp/4.12.0')).toBe('Torqvoice technician app')
+    expect(describeUserAgent('curl/8.5.0')).toBe('Unknown device')
+    expect(describeUserAgent(null)).toBe('Unknown device')
+  })
+})
+
+describe('classifyUserAgent', () => {
+  it('tells phones, tablets and computers apart', () => {
+    expect(
+      classifyUserAgent(
+        'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1'
+      )
+    ).toBe('phone')
+    expect(
+      classifyUserAgent(
+        'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/128.0.0.0 Mobile Safari/537.36'
+      )
+    ).toBe('phone')
+    expect(
+      classifyUserAgent(
+        'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 Version/17.5 Mobile/15E148 Safari/604.1'
+      )
+    ).toBe('tablet')
+    expect(
+      classifyUserAgent(
+        'Mozilla/5.0 (Linux; Android 13; SM-X710) AppleWebKit/537.36 Chrome/128.0.0.0 Safari/537.36'
+      )
+    ).toBe('tablet')
+    expect(
+      classifyUserAgent(
+        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/128.0.0.0 Safari/537.36'
+      )
+    ).toBe('desktop')
+    expect(classifyUserAgent('okhttp/4.12.0')).toBe('app')
+    expect(classifyUserAgent(null)).toBe('unknown')
+  })
+})

+ 9 - 0
src/app/(authenticated)/settings/account/account-settings.tsx

@@ -1,6 +1,8 @@
 'use client'
 
 import { AppCard } from '@/components/app-card'
+import { SignedInDevices } from './signed-in-devices'
+import type { SignedInDevice } from '@/features/settings/Actions/sessionActions'
 import { useState, useEffect } from 'react'
 import { useSession } from '@/lib/auth-client'
 import { authClient } from '@/lib/auth-client'
@@ -41,10 +43,12 @@ export function AccountSettings({
   twoFactorEnabled: initialTwoFactorEnabled,
   emailVerified: initialEmailVerified,
   emailVerificationRequired,
+  devices,
 }: {
   twoFactorEnabled: boolean
   emailVerified: boolean
   emailVerificationRequired: boolean
+  devices: SignedInDevice[]
 }) {
   const { data: session } = useSession()
   const router = useRouter()
@@ -231,6 +235,9 @@ export function AccountSettings({
       const result = await authClient.changePassword({
         currentPassword,
         newPassword,
+        // A new password is often a response to a stolen one: end every
+        // other session, and leave this one signed in.
+        revokeOtherSessions: true,
       })
       if (result.error) {
         toast.error(result.error.message || t('account.failedChangePassword'))
@@ -441,6 +448,8 @@ export function AccountSettings({
         )}
       </AppCard>
 
+      <SignedInDevices devices={devices} />
+
       {/* 2FA Setup Dialog */}
       <Dialog
         open={dialogOpen}

+ 4 - 1
src/app/(authenticated)/settings/account/page.tsx

@@ -3,12 +3,13 @@ import { auth } from '@/lib/auth'
 import { db } from '@/lib/db'
 import { redirect } from 'next/navigation'
 import { AccountSettings } from './account-settings'
+import { listMyDevices } from '@/features/settings/Actions/sessionActions'
 
 export default async function AccountSettingsPage() {
   const session = await auth.api.getSession({ headers: await headers() })
   if (!session) redirect('/auth/sign-in')
 
-  const [user, verificationSetting] = await Promise.all([
+  const [user, verificationSetting, devices] = await Promise.all([
     db.user.findUnique({
       where: { id: session.user.id },
       select: { twoFactorEnabled: true, emailVerified: true },
@@ -17,6 +18,7 @@ export default async function AccountSettingsPage() {
       where: { key: 'email.verificationRequired' },
       select: { value: true },
     }),
+    listMyDevices(),
   ])
 
   return (
@@ -24,6 +26,7 @@ export default async function AccountSettingsPage() {
       twoFactorEnabled={user?.twoFactorEnabled ?? false}
       emailVerified={user?.emailVerified ?? false}
       emailVerificationRequired={verificationSetting?.value === 'true'}
+      devices={devices}
     />
   )
 }

+ 169 - 0
src/app/(authenticated)/settings/account/signed-in-devices.tsx

@@ -0,0 +1,169 @@
+'use client'
+
+import { useState, useTransition } from 'react'
+import { useRouter } from 'next/navigation'
+import { useFormatter, useTranslations } from 'next-intl'
+import { toast } from 'sonner'
+import {
+  type LucideIcon,
+  Loader2,
+  LogOut,
+  Monitor,
+  MonitorSmartphone,
+  Smartphone,
+  Tablet,
+  Wrench,
+} from 'lucide-react'
+import { AppCard } from '@/components/app-card'
+import { Button } from '@/components/ui/button'
+import {
+  type SignedInDevice,
+  signOutDevice,
+  signOutOtherDevices,
+} from '@/features/settings/Actions/sessionActions'
+import type { DeviceKind } from '@/lib/known-devices'
+
+const ICONS: Record<DeviceKind, LucideIcon> = {
+  phone: Smartphone,
+  tablet: Tablet,
+  desktop: Monitor,
+  app: Wrench,
+  unknown: MonitorSmartphone,
+}
+
+/**
+ * Every open session on the account, drawn as the device it came from, with
+ * a way to end any of them. The browser looking at the page is marked and
+ * cannot be ended from here; sign out does that.
+ */
+export function SignedInDevices({ devices }: { devices: SignedInDevice[] }) {
+  const t = useTranslations('settings')
+  const format = useFormatter()
+  const router = useRouter()
+  const [pending, startTransition] = useTransition()
+  const [busy, setBusy] = useState<string | null>(null)
+
+  const others = devices.filter((d) => !d.current)
+
+  const endOne = (id: string) => {
+    setBusy(id)
+    startTransition(async () => {
+      const result = await signOutDevice(id)
+      setBusy(null)
+      if (result.success) {
+        toast.success(t('account.deviceSignedOut'))
+        router.refresh()
+      } else {
+        toast.error(t('account.failedSignOutDevice'))
+      }
+    })
+  }
+
+  const endOthers = () => {
+    setBusy('others')
+    startTransition(async () => {
+      const result = await signOutOtherDevices()
+      setBusy(null)
+      if (result.success) {
+        toast.success(t('account.othersSignedOut'))
+        router.refresh()
+      } else {
+        toast.error(t('account.failedSignOutDevice'))
+      }
+    })
+  }
+
+  const when = (iso: string) =>
+    format.dateTime(new Date(iso), { dateStyle: 'medium', timeStyle: 'short' })
+
+  return (
+    <AppCard
+      icon={MonitorSmartphone}
+      title={t('account.devicesTitle')}
+      description={t('account.devicesDescription')}
+      contentClassName="p-0"
+      action={
+        others.length > 0 ? (
+          <Button variant="outline" size="sm" onClick={endOthers} disabled={pending}>
+            {busy === 'others' ? (
+              <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+            ) : (
+              <LogOut className="mr-2 h-4 w-4" />
+            )}
+            {t('account.signOutOthers')}
+          </Button>
+        ) : undefined
+      }
+    >
+      <ul className="divide-y divide-border/60" data-testid="signed-in-devices">
+        {devices.map((device) => {
+          const Icon = ICONS[device.kind]
+          return (
+            <li
+              key={device.id}
+              className={`group flex items-center gap-4 px-6 py-4 transition-colors ${
+                device.current ? 'bg-primary/[0.03]' : 'hover:bg-muted/40'
+              }`}
+              data-testid="signed-in-device"
+            >
+              <div
+                className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border ${
+                  device.current
+                    ? 'border-primary/30 bg-primary/10 text-primary'
+                    : 'border-border bg-muted/50 text-muted-foreground'
+                }`}
+              >
+                <Icon className="h-5 w-5" strokeWidth={1.75} />
+              </div>
+
+              <div className="min-w-0 flex-1">
+                <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
+                  <span className="truncate text-sm font-medium">{device.label}</span>
+                  {device.current && (
+                    <span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-700 dark:text-emerald-400">
+                      <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
+                      {t('account.thisDevice')}
+                    </span>
+                  )}
+                </div>
+                <p className="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
+                  <span>{t('account.lastActiveAt', { date: when(device.lastActiveAt) })}</span>
+                  <span aria-hidden="true" className="text-border">
+                    ·
+                  </span>
+                  <span>{t('account.signedInAt', { date: when(device.createdAt) })}</span>
+                  {device.ip && (
+                    <>
+                      <span aria-hidden="true" className="text-border">
+                        ·
+                      </span>
+                      <span className="font-mono text-[11px]">{device.ip}</span>
+                    </>
+                  )}
+                </p>
+              </div>
+
+              {!device.current && (
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  onClick={() => endOne(device.id)}
+                  disabled={pending}
+                  className="shrink-0 text-muted-foreground hover:text-destructive"
+                  aria-label={`${t('account.signOutDevice')}: ${device.label}`}
+                >
+                  {busy === device.id ? (
+                    <Loader2 className="h-4 w-4 animate-spin" />
+                  ) : (
+                    <LogOut className="h-4 w-4" />
+                  )}
+                  <span className="ml-2 hidden sm:inline">{t('account.signOutDevice')}</span>
+                </Button>
+              )}
+            </li>
+          )
+        })}
+      </ul>
+    </AppCard>
+  )
+}

+ 17 - 4
src/app/api/public/auth/[...all]/route.ts

@@ -6,8 +6,9 @@ import { toNextJsHandler } from 'better-auth/next-js'
 import { db } from '@/lib/db'
 import { logAudit } from '@/lib/audit'
 import { explainInvalidOrigin } from '@/lib/auth-origin-hint'
+import { attachDeviceCookie, withDeviceCookie } from '@/lib/device-cookie'
 
-const { POST: authPOST, GET } = toNextJsHandler(auth)
+const { POST: authPOST, GET: authGET } = toNextJsHandler(auth)
 
 const authAuditPrefixes = [
   '/api/public/auth/sign-in',
@@ -37,7 +38,16 @@ function getRequestIp(request: Request): string | null {
   )
 }
 
-async function POST(request: Request) {
+/** OAuth callbacks arrive as GET and create sessions too. */
+async function GET(incoming: Request) {
+  const { request, issued } = withDeviceCookie(incoming)
+  return attachDeviceCookie(await authGET(request), issued)
+}
+
+async function POST(incoming: Request) {
+  // The browser's device id, minted here when it has none, so the session
+  // hook can tell a returning device from a new one.
+  const { request, issued } = withDeviceCookie(incoming)
   const { pathname } = new URL(request.url)
 
   if (isDemoMode && demoBlockedPrefixes.some((p) => pathname.startsWith(p))) {
@@ -63,7 +73,10 @@ async function POST(request: Request) {
   if (isAuthAttempt) {
     // Clone body before better-auth consumes it
     const cloned = request.clone()
-    const response = await explainInvalidOrigin(cloned, await authPOST(request))
+    const response = attachDeviceCookie(
+      await explainInvalidOrigin(cloned, await authPOST(request)),
+      issued
+    )
 
     // Log failed authentication attempts (fire-and-forget to avoid timing side-channels)
     if (!response.ok) {
@@ -107,7 +120,7 @@ async function POST(request: Request) {
     return response
   }
 
-  return explainInvalidOrigin(request, await authPOST(request))
+  return attachDeviceCookie(await explainInvalidOrigin(request, await authPOST(request)), issued)
 }
 
 export { GET, POST }

+ 8 - 3
src/components/app-sidebar.tsx

@@ -68,6 +68,7 @@ import {
   ShieldCheck,
   Timer,
   Users,
+  UserRound,
 } from 'lucide-react'
 import { useLocale, useTranslations } from 'next-intl'
 import { switchOrganization } from '@/features/team/Actions/switchOrganization'
@@ -635,11 +636,15 @@ export function AppSidebar({
                 align="start"
                 sideOffset={4}
               >
+                {/* The person's own page: name, password, devices. Settings for
+                    the workshop has its own row in the navigation above. The
+                    settings layout turns away a custom role without settings
+                    access, so the same rule gates this link. */}
                 {canAccess('settings') && (
                   <DropdownMenuItem asChild>
-                    <Link href="/settings">
-                      <Settings className="mr-2 size-4" />
-                      {t('sidebar.settings')}
+                    <Link href="/settings/account">
+                      <UserRound className="mr-2 size-4" />
+                      {t('sidebar.account')}
                     </Link>
                   </DropdownMenuItem>
                 )}

+ 11 - 23
src/features/settings/Actions/accountActions.ts

@@ -98,31 +98,19 @@ export async function requestEmailChange(data: { email: string }) {
       const baseURL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
       const confirmUrl = `${baseURL}/api/public/confirm-email-change?token=${token}&uid=${userId}`
 
-      const { sendMail, getFromAddress } = await import('@/lib/email')
-      const from = await getFromAddress()
-
-      await sendMail({
-        from,
+      const { sendAccountMail } = await import('@/lib/account-mail')
+      await sendAccountMail({
         to: parsed.email,
         subject: 'Confirm your new Torqvoice email',
-        html: `
-        <div style="font-family: sans-serif; max-width: 480px; margin: 0 auto;">
-          <h2>Email Change Confirmation</h2>
-          <p>Hi${user.name ? ` ${user.name}` : ''},</p>
-          <p>You requested to change your Torqvoice email to this address. Click the button below to confirm:</p>
-          <div style="margin: 24px 0;">
-            <a href="${confirmUrl}" style="display: inline-block; padding: 12px 24px; background-color: #171717; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
-              Confirm Email Change
-            </a>
-          </div>
-          <p style="color: #6b7280; font-size: 14px;">If you didn't request this change, you can safely ignore this email.</p>
-          <hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
-          <p style="color: #6b7280; font-size: 12px;">
-            This link will expire in 24 hours. If it doesn't work, copy and paste this URL into your browser:<br/>
-            <a href="${confirmUrl}" style="color: #6b7280;">${confirmUrl}</a>
-          </p>
-        </div>
-      `,
+        name: user.name,
+        paragraphs: [
+          'You asked to move your Torqvoice account to this email address. Open the link below to confirm it.',
+        ],
+        link: { text: 'Confirm your new email', url: confirmUrl },
+        notes: [
+          "If you didn't ask for this, you can ignore this mail and nothing changes.",
+          'The link expires in 24 hours.',
+        ],
       })
 
       return { sent: true }

+ 86 - 0
src/features/settings/Actions/sessionActions.ts

@@ -0,0 +1,86 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { getCachedSession } from '@/lib/cached-session'
+import { db } from '@/lib/db'
+import { classifyUserAgent, describeUserAgent, type DeviceKind } from '@/lib/known-devices'
+import { logAudit } from '@/lib/audit'
+import { demoGuard } from '@/lib/demo'
+import { z } from 'zod'
+
+export interface SignedInDevice {
+  id: string
+  label: string
+  kind: DeviceKind
+  ip: string | null
+  createdAt: string
+  lastActiveAt: string
+  current: boolean
+}
+
+/**
+ * The account's open sessions, newest first, as devices a person can
+ * recognise. Self-scoped by construction: the session decides the user, and
+ * every query below carries that user id.
+ */
+export async function listMyDevices(): Promise<SignedInDevice[]> {
+  const session = await getCachedSession()
+  if (!session?.user?.id) return []
+  const rows = await db.session.findMany({
+    where: { userId: session.user.id, expiresAt: { gt: new Date() } },
+    orderBy: { updatedAt: 'desc' },
+    select: { id: true, userAgent: true, ipAddress: true, createdAt: true, updatedAt: true },
+  })
+  return rows.map((row) => ({
+    id: row.id,
+    label: describeUserAgent(row.userAgent),
+    kind: classifyUserAgent(row.userAgent),
+    ip: row.ipAddress ?? null,
+    createdAt: row.createdAt.toISOString(),
+    lastActiveAt: row.updatedAt.toISOString(),
+    current: row.id === session.session.id,
+  }))
+}
+
+const sessionIdSchema = z.string().min(1).max(200)
+
+/**
+ * Ends one of the caller's own sessions. Deleting the row is what better-auth's
+ * own revoke does, and with no session cookie cache the device is refused on
+ * its very next request.
+ */
+export async function signOutDevice(input: unknown) {
+  // The demo's one account is shared by every visitor; nobody gets to sign
+  // the others out.
+  demoGuard()
+  const session = await getCachedSession()
+  if (!session?.user?.id) return { success: false as const, error: 'Unauthorized' }
+  const id = sessionIdSchema.parse(input)
+
+  const { count } = await db.session.deleteMany({ where: { id, userId: session.user.id } })
+  if (count === 0) return { success: false as const, error: 'Not found' }
+
+  logAudit(
+    { userId: session.user.id, organizationId: '' },
+    { action: 'auth.sessionRevoked', message: 'Signed out a device' }
+  ).catch(() => undefined)
+  revalidatePath('/settings/account')
+  return { success: true as const }
+}
+
+/** Ends every session of the caller's except the one making the call. */
+export async function signOutOtherDevices() {
+  demoGuard()
+  const session = await getCachedSession()
+  if (!session?.user?.id) return { success: false as const, error: 'Unauthorized' }
+
+  const { count } = await db.session.deleteMany({
+    where: { userId: session.user.id, id: { not: session.session.id } },
+  })
+  logAudit(
+    { userId: session.user.id, organizationId: '' },
+    { action: 'auth.sessionsRevoked', message: `Signed out ${count} other device(s)` }
+  ).catch(() => undefined)
+  revalidatePath('/settings/account')
+  return { success: true as const, data: { count } }
+}

+ 75 - 0
src/lib/account-mail.ts

@@ -0,0 +1,75 @@
+import 'server-only'
+
+/**
+ * The mails the platform sends to a person about their own account: verify
+ * the address, reset the password, confirm a new address, a new device.
+ *
+ * Written like a mail from a person, not a marketing piece: left-aligned
+ * text, a greeting, a few sentences, one ordinary link, a sign-off. No box
+ * in the middle of the page, no button. Every mail has a plain-text half.
+ * Nothing here is workshop-branded: these go through the platform sender
+ * and reach people who may belong to several workshops or none.
+ */
+export interface AccountMail {
+  to: string
+  subject: string
+  /** The person's name for the greeting; escaped here. */
+  name?: string | null
+  /** Sentences in order, one paragraph each; escaped here. */
+  paragraphs: string[]
+  /** The one thing to click, shown as a normal link with its address. */
+  link?: { text: string; url: string }
+  /** Quieter closing sentences: ignore if not you, expiry. Escaped here. */
+  notes?: string[]
+}
+
+export function escapeHtml(value: string): string {
+  return value
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+}
+
+/** Exported for tests: the two halves of the mail, from one description. */
+export function renderAccountMail(mail: AccountMail): { html: string; text: string } {
+  const greeting = mail.name ? `Hi ${mail.name.trim()},` : 'Hi,'
+  const link = mail.link
+  const notes = mail.notes ?? []
+
+  const text = [
+    greeting,
+    '',
+    ...mail.paragraphs.flatMap((p) => [p, '']),
+    ...(link ? [`${link.text}: ${link.url}`, ''] : []),
+    ...notes.flatMap((n) => [n, '']),
+    'Torqvoice',
+  ].join('\n')
+
+  const p = (s: string, muted = false) =>
+    `<p style="margin: 0 0 14px 0;${muted ? ' color: #555555;' : ''}">${escapeHtml(s)}</p>`
+
+  const html = [
+    '<div style="font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Helvetica, Arial, sans-serif; font-size: 15px; line-height: 1.5; color: #111111; text-align: left;">',
+    p(greeting),
+    ...mail.paragraphs.map((s) => p(s)),
+    ...(link
+      ? [
+          `<p style="margin: 0 0 14px 0;">${escapeHtml(link.text)}: <a href="${escapeHtml(link.url)}" style="color: #1a56db;">${escapeHtml(link.url)}</a></p>`,
+        ]
+      : []),
+    ...notes.map((s) => p(s, true)),
+    '<p style="margin: 18px 0 0 0;">Torqvoice</p>',
+    '</div>',
+  ].join('\n')
+
+  return { html, text }
+}
+
+/** Sends through the platform sender, never a workshop's own provider. */
+export async function sendAccountMail(mail: AccountMail): Promise<void> {
+  const { sendMail, getFromAddress } = await import('@/lib/email')
+  const from = await getFromAddress()
+  const { html, text } = renderAccountMail(mail)
+  await sendMail({ from, to: mail.to, subject: mail.subject, html, text })
+}

+ 63 - 49
src/lib/auth.ts

@@ -6,6 +6,8 @@ import { bearer } from 'better-auth/plugins/bearer'
 import { twoFactor } from 'better-auth/plugins/two-factor'
 import { db } from './db'
 import { logAudit } from './audit'
+import { noteDevice, sendNewDeviceMail } from '@/lib/known-devices'
+import { sendAccountMail } from '@/lib/account-mail'
 import { isDemoMode } from './demo'
 import { googleSignInConfig } from './auth-providers'
 
@@ -97,31 +99,13 @@ export const auth = betterAuth({
       })
 
       try {
-        const { sendMail, getFromAddress } = await import('@/lib/email')
-        const from = await getFromAddress()
-
-        await sendMail({
-          from,
+        await sendAccountMail({
           to: user.email,
           subject: 'Verify your Torqvoice email',
-          html: `
-            <div style="font-family: sans-serif; max-width: 480px; margin: 0 auto;">
-              <h2>Email Verification</h2>
-              <p>Hi${user.name ? ` ${user.name}` : ''},</p>
-              <p>Please verify your email address by clicking the button below:</p>
-              <div style="margin: 24px 0;">
-                <a href="${url}" style="display: inline-block; padding: 12px 24px; background-color: #171717; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
-                  Verify Email
-                </a>
-              </div>
-              <p style="color: #6b7280; font-size: 14px;">If you didn't create an account, you can safely ignore this email.</p>
-              <hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
-              <p style="color: #6b7280; font-size: 12px;">
-                If the button doesn't work, copy and paste this URL into your browser:<br/>
-                <a href="${url}" style="color: #6b7280;">${url}</a>
-              </p>
-            </div>
-          `,
+          name: user.name,
+          paragraphs: ['Please confirm this is your email address by opening the link below.'],
+          link: { text: 'Verify your email', url },
+          notes: ["If you didn't create a Torqvoice account, you can ignore this mail."],
         })
       } catch (error) {
         console.error('[emailVerification] Failed to send verification email:', error)
@@ -173,41 +157,36 @@ export const auth = betterAuth({
   },
   emailAndPassword: {
     enabled: true,
+    // A reset is how a person recovers from a stolen password; leaving the
+    // thief's sessions alive would make it theatre. Change-password passes
+    // revokeOtherSessions from the form for the same reason.
+    revokeSessionsOnPasswordReset: true,
     sendResetPassword: async ({ user, url }) => {
-      const { sendMail, getFromAddress } = await import('@/lib/email')
-      const from = await getFromAddress()
-
-      await sendMail({
-        from,
+      await sendAccountMail({
         to: user.email,
         subject: 'Reset your Torqvoice password',
-        html: `
-          <div style="font-family: sans-serif; max-width: 480px; margin: 0 auto;">
-            <h2>Password Reset</h2>
-            <p>Hi${user.name ? ` ${user.name}` : ''},</p>
-            <p>We received a request to reset your password. Click the button below to set a new password:</p>
-            <div style="margin: 24px 0;">
-              <a href="${url}" style="display: inline-block; padding: 12px 24px; background-color: #171717; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
-                Reset Password
-              </a>
-            </div>
-            <p style="color: #6b7280; font-size: 14px;">If you didn't request this, you can safely ignore this email.</p>
-            <hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
-            <p style="color: #6b7280; font-size: 12px;">
-              This link will expire shortly. If it doesn't work, copy and paste this URL into your browser:<br/>
-              <a href="${url}" style="color: #6b7280;">${url}</a>
-            </p>
-          </div>
-        `,
+        name: user.name,
+        paragraphs: [
+          'We received a request to reset the password on your Torqvoice account. Open the link below to choose a new one.',
+        ],
+        link: { text: 'Reset your password', url },
+        notes: [
+          "If you didn't ask for this, you can ignore this mail and your password stays as it is.",
+          'The link expires shortly.',
+        ],
       })
     },
   },
   session: {
     expiresIn: 60 * 60 * 24 * 7, // 7 days
     updateAge: 60 * 60 * 24, // 1 day
+    // No cookie cache. It saved one session lookup per request and in return
+    // let a revoked session keep working for up to five minutes: a phone
+    // signed out from the devices list stayed signed in, and a password
+    // change did not end the other browser until the cache ran out. The
+    // session table is read on every request now; membership already was.
     cookieCache: {
-      enabled: true,
-      maxAge: 5 * 60, // 5 minutes
+      enabled: false,
     },
   },
   advanced: {
@@ -228,12 +207,47 @@ export const auth = betterAuth({
   databaseHooks: {
     session: {
       create: {
-        after: async (session) => {
+        after: async (session, ctx) => {
           await db.user.update({
             where: { id: session.userId },
             data: { lastLogin: new Date() },
           })
 
+          // Which device this is, and a mail when the account has not seen
+          // it before. Its first device is recorded without a word: that is
+          // the sign-up, or an account from before devices were tracked. The
+          // demo's one shared account is every visitor's browser and sends no
+          // mail, so it is not tracked at all.
+          const sighting = isDemoMode
+            ? null
+            : await noteDevice(
+                {
+                  id: session.id,
+                  userId: session.userId,
+                  userAgent: ((session as Record<string, unknown>).userAgent as string) ?? null,
+                  ipAddress: ((session as Record<string, unknown>).ipAddress as string) ?? null,
+                },
+                ctx
+              ).catch((error) => {
+                console.error('[auth] could not record the device:', error)
+                return null
+              })
+          if (sighting?.isNew && !sighting.isFirst) {
+            const account = await db.user.findUnique({
+              where: { id: session.userId },
+              select: { email: true, name: true },
+            })
+            if (account?.email) {
+              sendNewDeviceMail({
+                to: account.email,
+                name: account.name,
+                label: sighting.label,
+                ip: ((session as Record<string, unknown>).ipAddress as string) ?? null,
+                at: new Date(),
+              }).catch((error) => console.error('[auth] new-device mail failed:', error))
+            }
+          }
+
           // Audit: log successful login
           const membership = await db.organizationMember.findFirst({
             where: { userId: session.userId },

+ 55 - 0
src/lib/device-cookie.ts

@@ -0,0 +1,55 @@
+import { randomBytes } from 'node:crypto'
+
+/** Long-lived cookie naming this browser to the app, across sign-ins. */
+export const DEVICE_COOKIE = 'torqvoice-device'
+const DEVICE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365
+const DEVICE_ID_PATTERN = /^[a-f0-9]{48}$/
+
+/** The device id a request carries, or null when the browser has none yet. */
+export function readDeviceCookie(
+  headers: { get(name: string): string | null } | undefined
+): string | null {
+  const raw = headers?.get('cookie')
+  if (!raw) return null
+  for (const part of raw.split(';')) {
+    const [name, ...rest] = part.trim().split('=')
+    if (name === DEVICE_COOKIE) {
+      const value = rest.join('=').trim()
+      return DEVICE_ID_PATTERN.test(value) ? value : null
+    }
+  }
+  return null
+}
+
+/**
+ * Gives a browser its device id before better-auth sees the request.
+ *
+ * The session hook that records devices runs after the response has been
+ * put together, so a cookie set from there never reaches the browser. The
+ * id is minted here instead, added to the request's cookie header so the
+ * hook reads it like any returning device, and set on the response so the
+ * browser brings it back next time.
+ */
+export function withDeviceCookie(request: Request): { request: Request; issued: string | null } {
+  if (readDeviceCookie(request.headers)) return { request, issued: null }
+
+  const issued = randomBytes(24).toString('hex')
+  const headers = new Headers(request.headers)
+  const existing = headers.get('cookie')
+  headers.set(
+    'cookie',
+    existing ? `${existing}; ${DEVICE_COOKIE}=${issued}` : `${DEVICE_COOKIE}=${issued}`
+  )
+  return { request: new Request(request, { headers }), issued }
+}
+
+/** Sets the freshly issued id on the response, leaving other cookies alone. */
+export function attachDeviceCookie(response: Response, issued: string | null): Response {
+  if (!issued) return response
+  const secure = (process.env.NEXT_PUBLIC_APP_URL ?? '').startsWith('https://')
+  response.headers.append(
+    'set-cookie',
+    `${DEVICE_COOKIE}=${issued}; Path=/; Max-Age=${DEVICE_COOKIE_MAX_AGE}; HttpOnly; SameSite=Lax${secure ? '; Secure' : ''}`
+  )
+  return response
+}

+ 184 - 0
src/lib/known-devices.ts

@@ -0,0 +1,184 @@
+import 'server-only'
+import { createHash } from 'node:crypto'
+import { db } from '@/lib/db'
+import { sendAccountMail } from '@/lib/account-mail'
+import { DEVICE_COOKIE, readDeviceCookie } from '@/lib/device-cookie'
+
+/**
+ * What better-auth hands a database hook: the endpoint context of the request
+ * that created the row, when there is one. Only the request headers are read,
+ * and only when present; a session minted outside a request has none.
+ */
+interface HookContext {
+  headers?: { get(name: string): string | null } | null
+  getCookie?: (name: string) => string | undefined | null
+}
+
+export interface DeviceSighting {
+  /** No row existed for this device before now. */
+  isNew: boolean
+  /** The account had no devices at all; recorded quietly, nothing to warn about. */
+  isFirst: boolean
+  label: string
+}
+
+/**
+ * "Chrome on Windows", "Safari on iPhone": enough for a person to recognise
+ * their own device in a mail or a list, from a user agent string. Not a
+ * fingerprint; the cookie does that.
+ */
+export function describeUserAgent(userAgent: string | null | undefined): string {
+  const ua = userAgent ?? ''
+  if (!ua) return 'Unknown device'
+  if (/Torqvoice|Expo|okhttp|Dart|CFNetwork/i.test(ua) && !/Mozilla/i.test(ua)) {
+    return 'Torqvoice technician app'
+  }
+  const os = /iPhone/.test(ua)
+    ? 'iPhone'
+    : /iPad/.test(ua)
+      ? 'iPad'
+      : /Android/.test(ua)
+        ? 'Android'
+        : /Windows/.test(ua)
+          ? 'Windows'
+          : /Mac OS X|Macintosh/.test(ua)
+            ? 'Mac'
+            : /CrOS/.test(ua)
+              ? 'ChromeOS'
+              : /Linux/.test(ua)
+                ? 'Linux'
+                : null
+  const browser = /Edg\//.test(ua)
+    ? 'Edge'
+    : /OPR\/|Opera/.test(ua)
+      ? 'Opera'
+      : /SamsungBrowser/.test(ua)
+        ? 'Samsung Internet'
+        : /Firefox\//.test(ua)
+          ? 'Firefox'
+          : /Chrome\/|CriOS\//.test(ua)
+            ? 'Chrome'
+            : /Safari\//.test(ua)
+              ? 'Safari'
+              : null
+  if (browser && os) return `${browser} on ${os}`
+  return browser ?? os ?? 'Unknown device'
+}
+
+export type DeviceKind = 'phone' | 'tablet' | 'desktop' | 'app' | 'unknown'
+
+/** Phone, tablet or computer: which picture to draw beside a session. */
+export function classifyUserAgent(userAgent: string | null | undefined): DeviceKind {
+  const ua = userAgent ?? ''
+  if (!ua) return 'unknown'
+  if (/Torqvoice|Expo|okhttp|Dart|CFNetwork/i.test(ua) && !/Mozilla/i.test(ua)) return 'app'
+  if (/iPad|Tablet|PlayBook|Silk/i.test(ua) || (/Android/i.test(ua) && !/Mobile/i.test(ua))) {
+    return 'tablet'
+  }
+  if (/iPhone|iPod|Android|Mobile|Windows Phone/i.test(ua)) return 'phone'
+  if (/Windows|Macintosh|Mac OS X|CrOS|Linux|X11/i.test(ua)) return 'desktop'
+  return 'unknown'
+}
+
+function useSecureCookies(): boolean {
+  return (process.env.NEXT_PUBLIC_APP_URL ?? '').startsWith('https://')
+}
+
+/**
+ * Records the device a session was just created from, and says whether the
+ * account has seen it before.
+ *
+ * A browser is known by the id in its device cookie, issued by the auth
+ * route on first sight and kept for a year, so a sign-out or a password change that ends
+ * every session does not turn the same laptop into a "new device" next week.
+ * A client that keeps no cookies, such as the technician app, is known by
+ * its user agent instead: coarser, but stable for one phone.
+ *
+ * The first device an account ever sees is recorded quietly; a device that
+ * shows up while another session is already open is a new one, even if it
+ * is the first row here.
+ */
+export async function noteDevice(
+  session: { id: string; userId: string; userAgent?: string | null; ipAddress?: string | null },
+  ctx: unknown
+): Promise<DeviceSighting> {
+  const hook = (ctx ?? {}) as HookContext
+  const label = describeUserAgent(session.userAgent)
+
+  // The auth route issues the cookie before better-auth runs (device-cookie.ts),
+  // so a returning browser and a brand-new one both carry it here.
+  let deviceKey =
+    readDeviceCookie(hook.headers ?? undefined) ?? hook.getCookie?.(DEVICE_COOKIE) ?? null
+  if (!deviceKey) {
+    deviceKey = `ua:${createHash('sha256')
+      .update(session.userAgent ?? '')
+      .digest('hex')}`
+  }
+
+  const now = new Date()
+  const known = await db.userDevice.findUnique({
+    where: { userId_deviceKey: { userId: session.userId, deviceKey } },
+    select: { id: true },
+  })
+  if (known) {
+    await db.userDevice.update({
+      where: { id: known.id },
+      data: {
+        lastSeenAt: now,
+        lastIp: session.ipAddress ?? undefined,
+        userAgent: session.userAgent ?? undefined,
+      },
+    })
+    return { isNew: false, isFirst: false, label }
+  }
+
+  // "First" means the account has never been used anywhere: no device on
+  // record and no other session open. An account from before devices were
+  // tracked has no rows, but a laptop still signed in says it is in use, so
+  // a phone appearing beside it is news, not a first sighting.
+  const [knownDevices, otherSessions] = await Promise.all([
+    db.userDevice.count({ where: { userId: session.userId } }),
+    db.session.count({
+      where: { userId: session.userId, id: { not: session.id }, expiresAt: { gt: now } },
+    }),
+  ])
+  const others = knownDevices + otherSessions
+  await db.userDevice.create({
+    data: {
+      userId: session.userId,
+      deviceKey,
+      userAgent: session.userAgent ?? null,
+      lastIp: session.ipAddress ?? null,
+      firstSeenAt: now,
+      lastSeenAt: now,
+    },
+  })
+  return { isNew: true, isFirst: others === 0, label }
+}
+
+/**
+ * "A new device signed in": sent to the account's address, from the
+ * platform sender, never through a workshop's own mail setup. Best effort;
+ * the sign-in has already happened and must not fail on a mail error.
+ */
+export async function sendNewDeviceMail(input: {
+  to: string
+  name?: string | null
+  label: string
+  ip?: string | null
+  at: Date
+}): Promise<void> {
+  const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? 'https://app.torqvoice.com'
+  const where = input.ip ? ` from ${input.ip}` : ''
+  await sendAccountMail({
+    to: input.to,
+    subject: 'New sign-in to your Torqvoice account',
+    name: input.name,
+    paragraphs: [
+      `A new device signed in to your Torqvoice account: ${input.label}${where}, ${input.at.toUTCString()}.`,
+      'If this was you, there is nothing to do.',
+      'If it was not, change your password and sign out the other devices from your account page.',
+    ],
+    link: { text: 'Review signed-in devices', url: `${appUrl}/settings/account` },
+  })
+}