Преглед изворни кода

Tighten account, technician app and upload handling (#449)

* hardning

* Honour the email verification setting on the server

* Keep a verified video as it came when it cannot be re-encoded
Bernt Christian Egeland пре 3 недеља
родитељ
комит
8fd9ea6c7b
73 измењених фајлова са 2488 додато и 235 уклоњено
  1. 10 1
      e2e/specs/work-orders/modern-page.spec.ts
  2. 8 0
      messages/de/settings.json
  3. 8 0
      messages/en/settings.json
  4. 8 0
      messages/es/settings.json
  5. 8 0
      messages/fr/settings.json
  6. 8 0
      messages/it/settings.json
  7. 8 0
      messages/lt/settings.json
  8. 8 0
      messages/nb/settings.json
  9. 8 0
      messages/nl/settings.json
  10. 8 0
      messages/pl/settings.json
  11. 8 0
      messages/pt-BR/settings.json
  12. 8 0
      messages/pt-PT/settings.json
  13. 8 0
      messages/ru/settings.json
  14. 8 0
      messages/tr/settings.json
  15. 8 0
      prisma/migrations/20261008040548_session_organization_scope/migration.sql
  16. 14 0
      prisma/schema/auth.prisma
  17. 1 0
      prisma/schema/organization.prisma
  18. 11 1
      src/__tests__/api/tech-phone-login.test.ts
  19. 16 1
      src/__tests__/api/tech-setup-redeem.test.ts
  20. 38 31
      src/__tests__/features/auth/account-actions.test.ts
  21. 24 0
      src/__tests__/features/auth/confirm-email-change.test.ts
  22. 150 0
      src/__tests__/features/team/app-setup-code-standing.test.ts
  23. 42 1
      src/__tests__/features/team/invitation-rules.test.ts
  24. 157 7
      src/__tests__/features/technician-account.test.ts
  25. 42 0
      src/__tests__/features/vehicles/note-schema.test.ts
  26. 1 1
      src/__tests__/lib/files/delete-paths.test.ts
  27. 51 7
      src/__tests__/lib/files/manager.test.ts
  28. 260 0
      src/__tests__/lib/reauth.test.ts
  29. 220 0
      src/__tests__/lib/session-scope.test.ts
  30. 94 0
      src/__tests__/lib/video-sniff.test.ts
  31. 3 1
      src/app/(authenticated)/admin/layout.tsx
  32. 8 15
      src/app/(authenticated)/settings/account/account-settings.tsx
  33. 49 8
      src/app/(authenticated)/settings/data/data-settings.tsx
  34. 4 0
      src/app/(authenticated)/settings/data/page.tsx
  35. 84 0
      src/app/(authenticated)/settings/data/reauth-field.tsx
  36. 2 1
      src/app/(authenticated)/vehicles/[id]/vehicle-detail-client.tsx
  37. 21 1
      src/app/api/protected/upload/quote-files/route.ts
  38. 87 22
      src/app/api/protected/upload/service-files/route.ts
  39. 12 5
      src/app/api/protected/ws/route.ts
  40. 24 0
      src/app/api/public/confirm-email-change/route.ts
  41. 23 2
      src/app/api/v1/tech/jobs/[id]/attachments/route.ts
  42. 25 1
      src/app/api/v1/tech/jobs/[id]/status-report/route.ts
  43. 6 1
      src/app/api/v1/tech/org/[orgId]/auth/verify/route.ts
  44. 7 1
      src/app/api/v1/tech/setup/redeem/route.ts
  45. 20 11
      src/app/api/v1/tech/ws/route.ts
  46. 59 35
      src/features/settings/Actions/accountActions.ts
  47. 33 21
      src/features/settings/Actions/deleteAccount.ts
  48. 9 0
      src/features/settings/Actions/sessionActions.ts
  49. 19 2
      src/features/team/Actions/createAppSetupCode.ts
  50. 3 0
      src/features/team/Actions/createNewOrganization.ts
  51. 71 6
      src/features/team/Actions/createTechnicianAccount.ts
  52. 8 3
      src/features/team/Actions/deleteWorkshop.ts
  53. 8 2
      src/features/team/Actions/removeTechnicianAccess.ts
  54. 8 0
      src/features/team/Actions/switchOrganization.ts
  55. 13 2
      src/features/team/Actions/teamActions.ts
  56. 47 0
      src/features/team/Lib/appSetupRules.ts
  57. 29 13
      src/features/team/Lib/revokeTechnicianCredentials.ts
  58. 13 2
      src/features/vehicles/Schema/noteSchema.ts
  59. 38 2
      src/lib/auth.ts
  60. 2 0
      src/lib/backup/manifest.ts
  61. 36 1
      src/lib/cached-session.ts
  62. 3 2
      src/lib/delete-user-data.ts
  63. 22 0
      src/lib/email-verification-policy.ts
  64. 68 19
      src/lib/files/manager.ts
  65. 4 1
      src/lib/get-auth-context.ts
  66. 4 1
      src/lib/get-layout-data.ts
  67. 137 0
      src/lib/reauth.server.ts
  68. 34 0
      src/lib/reauth.ts
  69. 93 0
      src/lib/session-scope.ts
  70. 72 0
      src/lib/video-sniff.ts
  71. 17 2
      src/lib/with-api-auth.ts
  72. 16 1
      src/lib/with-auth.ts
  73. 4 1
      src/lib/with-super-admin.ts

+ 10 - 1
e2e/specs/work-orders/modern-page.spec.ts

@@ -295,15 +295,24 @@ test.describe('the overhauled work order page', () => {
 
     // A video goes to the customer too, as on the classic page: the shared
     // link plays it. And it plays here, not in a new tab.
+    // The server reads the bytes before it believes the file is a video, and
+    // these are not one, so the upload's answer is stood in for. What is
+    // under test here is the card and the player, not the upload.
     const clip = `e2e-clip-${stamp}.mp4`
+    const clipUrl = `/api/protected/files/${await ownerOrganizationId()}/services/e2e-clip-${stamp}.mp4`
+    await page.route('**/api/protected/upload/service-files', (route) =>
+      route.fulfill({
+        json: { url: clipUrl, fileName: clip, fileType: 'video/mp4', fileSize: 1024 },
+      })
+    )
     await files.getByRole('tab', { name: /^Video/ }).click()
     await files.locator('input[type="file"]').setInputFiles({
       name: clip,
       mimeType: 'video/mp4',
-      // The server keeps the bytes as they came when it cannot re-encode them.
       buffer: Buffer.from(`not really a video ${stamp}`),
     })
     await expect(tiles).toHaveCount(1)
+    await page.unroute('**/api/protected/upload/service-files')
     await expect(tiles.first()).toContainText('Customer can see')
     await tiles.first().getByRole('button', { name: clip }).click()
     const player = page.getByRole('dialog', { name: clip })

+ 8 - 0
messages/de/settings.json

@@ -347,6 +347,14 @@
     "deleteWorkshopConfirmPrompt": "Geben Sie <bold>{name}</bold> ein, um zu bestätigen.",
     "deleteWorkshopSuccess": "Werkstatt gelöscht",
     "deleteWorkshopFailed": "Werkstatt konnte nicht gelöscht werden",
+    "reauthPasswordLabel": "Ihr Passwort",
+    "reauthTotpLabel": "Code aus Ihrer Authenticator-App",
+    "reauthFreshSessionHint": "Ihr Konto hat kein Passwort, daher funktioniert dies nur innerhalb von {minutes} Minuten nach der Anmeldung. Ist es länger her, melden Sie sich zuerst ab und wieder an.",
+    "reauthWrongPassword": "Falsches Passwort.",
+    "reauthWrongCode": "Falscher Code.",
+    "reauthTooManyAttempts": "Zu viele Versuche. Warten Sie einige Minuten und versuchen Sie es erneut.",
+    "reauthSignInAgainAccount": "Melden Sie sich erneut an, bevor Sie Ihr Konto löschen.",
+    "reauthSignInAgainWorkshop": "Melden Sie sich erneut an, bevor Sie die Werkstatt löschen.",
     "deleteAccountMoved": "Möchten Sie Ihr Konto löschen?",
     "deleteAccountMovedLink": "Das können Sie in den Dateneinstellungen tun.",
     "signatureTitle": "Unterschrift",

+ 8 - 0
messages/en/settings.json

@@ -347,6 +347,14 @@
     "deleteWorkshopConfirmPrompt": "Type <bold>{name}</bold> to confirm.",
     "deleteWorkshopSuccess": "Workshop deleted",
     "deleteWorkshopFailed": "Failed to delete the workshop",
+    "reauthPasswordLabel": "Your password",
+    "reauthTotpLabel": "Code from your authenticator app",
+    "reauthFreshSessionHint": "Your account has no password, so this only works within {minutes} minutes of signing in. If it has been longer, sign out and sign in again first.",
+    "reauthWrongPassword": "Wrong password.",
+    "reauthWrongCode": "Wrong code.",
+    "reauthTooManyAttempts": "Too many attempts. Wait a few minutes and try again.",
+    "reauthSignInAgainAccount": "Sign in again before deleting your account.",
+    "reauthSignInAgainWorkshop": "Sign in again before deleting the workshop.",
     "deleteAccountMoved": "Want to delete your account?",
     "deleteAccountMovedLink": "You can do that in Data settings.",
     "signatureTitle": "Signature",

+ 8 - 0
messages/es/settings.json

@@ -347,6 +347,14 @@
     "deleteWorkshopConfirmPrompt": "Escriba <bold>{name}</bold> para confirmar.",
     "deleteWorkshopSuccess": "Taller eliminado",
     "deleteWorkshopFailed": "No se pudo eliminar el taller",
+    "reauthPasswordLabel": "Su contraseña",
+    "reauthTotpLabel": "Código de su aplicación de autenticación",
+    "reauthFreshSessionHint": "Su cuenta no tiene contraseña, por lo que esto solo funciona durante los {minutes} minutos siguientes a iniciar sesión. Si ha pasado más tiempo, cierre sesión y vuelva a iniciarla primero.",
+    "reauthWrongPassword": "Contraseña incorrecta.",
+    "reauthWrongCode": "Código incorrecto.",
+    "reauthTooManyAttempts": "Demasiados intentos. Espere unos minutos e inténtelo de nuevo.",
+    "reauthSignInAgainAccount": "Vuelva a iniciar sesión antes de eliminar su cuenta.",
+    "reauthSignInAgainWorkshop": "Vuelva a iniciar sesión antes de eliminar el taller.",
     "deleteAccountMoved": "¿Quiere eliminar su cuenta?",
     "deleteAccountMovedLink": "Puede hacerlo en la configuración de datos.",
     "signatureTitle": "Firma",

+ 8 - 0
messages/fr/settings.json

@@ -347,6 +347,14 @@
     "deleteWorkshopConfirmPrompt": "Saisissez <bold>{name}</bold> pour confirmer.",
     "deleteWorkshopSuccess": "Atelier supprimé",
     "deleteWorkshopFailed": "Échec de la suppression de l'atelier",
+    "reauthPasswordLabel": "Votre mot de passe",
+    "reauthTotpLabel": "Code de votre application d'authentification",
+    "reauthFreshSessionHint": "Votre compte n'a pas de mot de passe : cette action n'est donc possible que dans les {minutes} minutes qui suivent la connexion. Si c'est plus ancien, déconnectez-vous puis reconnectez-vous d'abord.",
+    "reauthWrongPassword": "Mot de passe incorrect.",
+    "reauthWrongCode": "Code incorrect.",
+    "reauthTooManyAttempts": "Trop de tentatives. Patientez quelques minutes puis réessayez.",
+    "reauthSignInAgainAccount": "Reconnectez-vous avant de supprimer votre compte.",
+    "reauthSignInAgainWorkshop": "Reconnectez-vous avant de supprimer l'atelier.",
     "deleteAccountMoved": "Vous souhaitez supprimer votre compte ?",
     "deleteAccountMovedLink": "Vous pouvez le faire dans les paramètres des données.",
     "signatureTitle": "Signature",

+ 8 - 0
messages/it/settings.json

@@ -347,6 +347,14 @@
     "deleteWorkshopConfirmPrompt": "Digita <bold>{name}</bold> per confermare.",
     "deleteWorkshopSuccess": "Officina eliminata",
     "deleteWorkshopFailed": "Impossibile eliminare l'officina",
+    "reauthPasswordLabel": "La tua password",
+    "reauthTotpLabel": "Codice dalla tua app di autenticazione",
+    "reauthFreshSessionHint": "Il tuo account non ha una password, quindi questa operazione è possibile solo entro {minutes} minuti dall'accesso. Se è passato più tempo, esci e accedi di nuovo prima.",
+    "reauthWrongPassword": "Password errata.",
+    "reauthWrongCode": "Codice errato.",
+    "reauthTooManyAttempts": "Troppi tentativi. Attendi qualche minuto e riprova.",
+    "reauthSignInAgainAccount": "Accedi di nuovo prima di eliminare il tuo account.",
+    "reauthSignInAgainWorkshop": "Accedi di nuovo prima di eliminare l'officina.",
     "deleteAccountMoved": "Vuoi eliminare il tuo account?",
     "deleteAccountMovedLink": "Puoi farlo nelle impostazioni dei dati.",
     "signatureTitle": "Firma",

+ 8 - 0
messages/lt/settings.json

@@ -347,6 +347,14 @@
     "deleteWorkshopConfirmPrompt": "Įveskite <bold>{name}</bold>, kad patvirtintumėte.",
     "deleteWorkshopSuccess": "Dirbtuvės ištrintos",
     "deleteWorkshopFailed": "Nepavyko ištrinti dirbtuvių",
+    "reauthPasswordLabel": "Jūsų slaptažodis",
+    "reauthTotpLabel": "Kodas iš jūsų autentifikavimo programėlės",
+    "reauthFreshSessionHint": "Jūsų paskyra neturi slaptažodžio, todėl tai veikia tik per {minutes} min. nuo prisijungimo. Jei praėjo daugiau laiko, pirmiausia atsijunkite ir vėl prisijunkite.",
+    "reauthWrongPassword": "Neteisingas slaptažodis.",
+    "reauthWrongCode": "Neteisingas kodas.",
+    "reauthTooManyAttempts": "Per daug bandymų. Palaukite kelias minutes ir bandykite dar kartą.",
+    "reauthSignInAgainAccount": "Prieš ištrindami paskyrą, prisijunkite iš naujo.",
+    "reauthSignInAgainWorkshop": "Prieš ištrindami dirbtuves, prisijunkite iš naujo.",
     "deleteAccountMoved": "Norite ištrinti savo paskyrą?",
     "deleteAccountMovedLink": "Tai galite padaryti duomenų nustatymuose.",
     "signatureTitle": "Parašas",

+ 8 - 0
messages/nb/settings.json

@@ -347,6 +347,14 @@
     "deleteWorkshopConfirmPrompt": "Skriv <bold>{name}</bold> for å bekrefte.",
     "deleteWorkshopSuccess": "Verksted slettet",
     "deleteWorkshopFailed": "Kunne ikke slette verkstedet",
+    "reauthPasswordLabel": "Passordet ditt",
+    "reauthTotpLabel": "Kode fra autentiseringsappen din",
+    "reauthFreshSessionHint": "Kontoen din har ikke passord, så dette fungerer bare innen {minutes} minutter etter at du logget inn. Har det gått lenger tid, logg ut og inn igjen først.",
+    "reauthWrongPassword": "Feil passord.",
+    "reauthWrongCode": "Feil kode.",
+    "reauthTooManyAttempts": "For mange forsøk. Vent noen minutter og prøv igjen.",
+    "reauthSignInAgainAccount": "Logg inn på nytt før du sletter kontoen din.",
+    "reauthSignInAgainWorkshop": "Logg inn på nytt før du sletter verkstedet.",
     "deleteAccountMoved": "Vil du slette kontoen din?",
     "deleteAccountMovedLink": "Det kan du gjøre i datainnstillingene.",
     "signatureTitle": "Signatur",

+ 8 - 0
messages/nl/settings.json

@@ -347,6 +347,14 @@
     "deleteWorkshopConfirmPrompt": "Typ <bold>{name}</bold> om te bevestigen.",
     "deleteWorkshopSuccess": "Werkplaats verwijderd",
     "deleteWorkshopFailed": "Kan de werkplaats niet verwijderen",
+    "reauthPasswordLabel": "Uw wachtwoord",
+    "reauthTotpLabel": "Code uit uw authenticatie-app",
+    "reauthFreshSessionHint": "Uw account heeft geen wachtwoord, dus dit werkt alleen binnen {minutes} minuten na het inloggen. Is het langer geleden, log dan eerst uit en weer in.",
+    "reauthWrongPassword": "Onjuist wachtwoord.",
+    "reauthWrongCode": "Onjuiste code.",
+    "reauthTooManyAttempts": "Te veel pogingen. Wacht een paar minuten en probeer het opnieuw.",
+    "reauthSignInAgainAccount": "Log opnieuw in voordat u uw account verwijdert.",
+    "reauthSignInAgainWorkshop": "Log opnieuw in voordat u de werkplaats verwijdert.",
     "deleteAccountMoved": "Wilt u uw account verwijderen?",
     "deleteAccountMovedLink": "Dat kan in de gegevensinstellingen.",
     "signatureTitle": "Handtekening",

+ 8 - 0
messages/pl/settings.json

@@ -347,6 +347,14 @@
     "deleteWorkshopConfirmPrompt": "Wpisz <bold>{name}</bold>, aby potwierdzić.",
     "deleteWorkshopSuccess": "Warsztat usunięty",
     "deleteWorkshopFailed": "Nie udało się usunąć warsztatu",
+    "reauthPasswordLabel": "Twoje hasło",
+    "reauthTotpLabel": "Kod z aplikacji uwierzytelniającej",
+    "reauthFreshSessionHint": "Twoje konto nie ma hasła, więc ta operacja działa tylko w ciągu {minutes} minut od zalogowania. Jeśli minęło więcej czasu, najpierw wyloguj się i zaloguj ponownie.",
+    "reauthWrongPassword": "Nieprawidłowe hasło.",
+    "reauthWrongCode": "Nieprawidłowy kod.",
+    "reauthTooManyAttempts": "Zbyt wiele prób. Odczekaj kilka minut i spróbuj ponownie.",
+    "reauthSignInAgainAccount": "Zaloguj się ponownie, zanim usuniesz konto.",
+    "reauthSignInAgainWorkshop": "Zaloguj się ponownie, zanim usuniesz warsztat.",
     "deleteAccountMoved": "Chcesz usunąć swoje konto?",
     "deleteAccountMovedLink": "Możesz to zrobić w ustawieniach danych.",
     "signatureTitle": "Podpis",

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

@@ -347,6 +347,14 @@
     "deleteWorkshopConfirmPrompt": "Digite <bold>{name}</bold> para confirmar.",
     "deleteWorkshopSuccess": "Oficina excluída",
     "deleteWorkshopFailed": "Falha ao excluir a oficina",
+    "reauthPasswordLabel": "Sua senha",
+    "reauthTotpLabel": "Código do seu aplicativo autenticador",
+    "reauthFreshSessionHint": "Sua conta não tem senha, então isso só funciona até {minutes} minutos depois de entrar. Se já passou mais tempo, saia e entre novamente primeiro.",
+    "reauthWrongPassword": "Senha incorreta.",
+    "reauthWrongCode": "Código incorreto.",
+    "reauthTooManyAttempts": "Muitas tentativas. Aguarde alguns minutos e tente novamente.",
+    "reauthSignInAgainAccount": "Entre novamente antes de excluir sua conta.",
+    "reauthSignInAgainWorkshop": "Entre novamente antes de excluir a oficina.",
     "deleteAccountMoved": "Quer excluir sua conta?",
     "deleteAccountMovedLink": "Você pode fazer isso nas configurações de dados.",
     "signatureTitle": "Assinatura",

+ 8 - 0
messages/pt-PT/settings.json

@@ -347,6 +347,14 @@
     "deleteWorkshopConfirmPrompt": "Escreva <bold>{name}</bold> para confirmar.",
     "deleteWorkshopSuccess": "Oficina eliminada",
     "deleteWorkshopFailed": "Não foi possível eliminar a oficina",
+    "reauthPasswordLabel": "A sua palavra-passe",
+    "reauthTotpLabel": "Código da sua aplicação de autenticação",
+    "reauthFreshSessionHint": "A sua conta não tem palavra-passe, por isso isto só funciona nos {minutes} minutos seguintes ao início de sessão. Se já passou mais tempo, termine a sessão e inicie-a novamente primeiro.",
+    "reauthWrongPassword": "Palavra-passe incorreta.",
+    "reauthWrongCode": "Código incorreto.",
+    "reauthTooManyAttempts": "Demasiadas tentativas. Aguarde alguns minutos e tente novamente.",
+    "reauthSignInAgainAccount": "Inicie sessão novamente antes de eliminar a sua conta.",
+    "reauthSignInAgainWorkshop": "Inicie sessão novamente antes de eliminar a oficina.",
     "deleteAccountMoved": "Quer eliminar a sua conta?",
     "deleteAccountMovedLink": "Pode fazê-lo nas definições de Dados.",
     "signatureTitle": "Assinatura",

+ 8 - 0
messages/ru/settings.json

@@ -347,6 +347,14 @@
     "deleteWorkshopConfirmPrompt": "Введите <bold>{name}</bold> для подтверждения.",
     "deleteWorkshopSuccess": "Мастерская удалена",
     "deleteWorkshopFailed": "Не удалось удалить мастерскую",
+    "reauthPasswordLabel": "Ваш пароль",
+    "reauthTotpLabel": "Код из приложения-аутентификатора",
+    "reauthFreshSessionHint": "У вашего аккаунта нет пароля, поэтому это возможно только в течение {minutes} минут после входа. Если прошло больше времени, сначала выйдите и войдите снова.",
+    "reauthWrongPassword": "Неверный пароль.",
+    "reauthWrongCode": "Неверный код.",
+    "reauthTooManyAttempts": "Слишком много попыток. Подождите несколько минут и попробуйте снова.",
+    "reauthSignInAgainAccount": "Войдите снова, прежде чем удалять аккаунт.",
+    "reauthSignInAgainWorkshop": "Войдите снова, прежде чем удалять мастерскую.",
     "deleteAccountMoved": "Хотите удалить свою учётную запись?",
     "deleteAccountMovedLink": "Это можно сделать в настройках данных.",
     "signatureTitle": "Подпись",

+ 8 - 0
messages/tr/settings.json

@@ -347,6 +347,14 @@
     "deleteWorkshopConfirmPrompt": "Onaylamak için <bold>{name}</bold> yazın.",
     "deleteWorkshopSuccess": "Atölye silindi",
     "deleteWorkshopFailed": "Atölye silinemedi",
+    "reauthPasswordLabel": "Şifreniz",
+    "reauthTotpLabel": "Doğrulayıcı uygulamanızdaki kod",
+    "reauthFreshSessionHint": "Hesabınızın şifresi yok, bu yüzden bu işlem yalnızca giriş yaptıktan sonraki {minutes} dakika içinde yapılabilir. Daha uzun süre geçtiyse önce çıkış yapıp yeniden giriş yapın.",
+    "reauthWrongPassword": "Yanlış şifre.",
+    "reauthWrongCode": "Yanlış kod.",
+    "reauthTooManyAttempts": "Çok fazla deneme. Birkaç dakika bekleyip tekrar deneyin.",
+    "reauthSignInAgainAccount": "Hesabınızı silmeden önce yeniden giriş yapın.",
+    "reauthSignInAgainWorkshop": "Atölyeyi silmeden önce yeniden giriş yapın.",
     "deleteAccountMoved": "Hesabınızı silmek mi istiyorsunuz?",
     "deleteAccountMovedLink": "Bunu veri ayarlarından yapabilirsiniz.",
     "signatureTitle": "İmza",

+ 8 - 0
prisma/migrations/20261008040548_session_organization_scope/migration.sql

@@ -0,0 +1,8 @@
+-- AlterTable
+ALTER TABLE "sessions" ADD COLUMN     "organizationId" TEXT;
+
+-- CreateIndex
+CREATE INDEX "sessions_organizationId_idx" ON "sessions"("organizationId");
+
+-- AddForeignKey
+ALTER TABLE "sessions" ADD CONSTRAINT "sessions_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 14 - 0
prisma/schema/auth.prisma

@@ -78,7 +78,21 @@ model Session {
   userId String
   user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
 
+  /// The one workshop this session may act in, for sessions minted without a
+  /// password: an app setup code from the desk, or a one-time code sent to a
+  /// technician's phone. Null for every ordinary sign-in.
+  ///
+  /// Those sessions are handed out on a workshop's say-so, and a workshop can
+  /// only vouch for itself. Left unscoped, the token was the person's whole
+  /// account: whoever redeemed a setup code could name any other workshop the
+  /// technician belongs to in a header and act there with their role. When
+  /// this is set it is the workshop, and no header or cookie can choose
+  /// another. Removing the workshop ends the session with it.
+  organizationId String?
+  organization   Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
   @@index([userId])
+  @@index([organizationId])
   @@map("sessions")
 }
 

+ 1 - 0
prisma/schema/organization.prisma

@@ -63,6 +63,7 @@ model Organization {
   timeEntries                 TimeEntry[]
   pushDevices                 PushDevice[]
   appSetupCodes               TechnicianSetupCode[]
+  scopedSessions              Session[]
   technicianLoginCodes        TechnicianLoginCode[]
   auditLogs                   AuditLog[]
   statusReports               StatusReport[]

+ 11 - 1
src/__tests__/api/tech-phone-login.test.ts

@@ -241,7 +241,17 @@ describe('verifying a code', () => {
     const res = await verify({ code: CODE, phone: '+4791234567' })
 
     expect(await res.json()).toEqual({ data: { token: 'session-token', organizationId: ORG } })
-    expect(createSession).toHaveBeenCalledWith('user-1', false)
+    expect(createSession).toHaveBeenCalledWith('user-1', false, { organizationId: ORG })
+  })
+
+  it('binds the session to this workshop and no other', async () => {
+    vi.mocked(db.technicianLoginCode.findFirst).mockResolvedValue(live() as never)
+    await verify({ code: CODE, phone: '+4791234567' })
+
+    // Written in the same insert that creates the session, so there is no
+    // moment in which the token exists without its workshop.
+    const [, , override] = createSession.mock.calls[0] ?? []
+    expect(override).toEqual({ organizationId: ORG })
   })
 
   it('finds the code by who is claiming it, inside this workshop', async () => {

+ 16 - 1
src/__tests__/api/tech-setup-redeem.test.ts

@@ -81,7 +81,22 @@ describe('POST /api/v1/tech/setup/redeem', () => {
       organizationId: 'org-1',
       workshop: 'Bay Street Motors',
     })
-    expect(mockCreateSession).toHaveBeenCalledWith('user-1', false)
+    expect(mockCreateSession).toHaveBeenCalledWith('user-1', false, { organizationId: 'org-1' })
+  })
+
+  it('binds the session to the workshop that issued the code', async () => {
+    // Whoever scans the code is signed in as this person, on the issuing
+    // desk's word alone. That word is good for one workshop: the session must
+    // not be pointable at any other the technician belongs to.
+    findCode.mockResolvedValue(live({ organizationId: 'org-issuer' }) as never)
+    findTechnician.mockResolvedValue({ id: 'tech-1', name: 'Kari' } as never)
+
+    const res = await post({ code: CODE })
+
+    expect(res.status).toBe(200)
+    const [userId, , override] = mockCreateSession.mock.calls[0] ?? []
+    expect(userId).toBe('user-1')
+    expect(override).toEqual({ organizationId: 'org-issuer' })
   })
 
   it('looks the code up by hash, never by its value', async () => {

+ 38 - 31
src/__tests__/features/auth/account-actions.test.ts

@@ -21,12 +21,15 @@ vi.mock('@/lib/db', () => ({
 }))
 
 // Mock email
+vi.mock('@/lib/email-verification-policy', () => ({ emailVerificationRequired: vi.fn() }))
+vi.mock('@/lib/account-mail', () => ({ sendAccountMail: vi.fn().mockResolvedValue(undefined) }))
 vi.mock('@/lib/email', () => ({
   sendMail: vi.fn().mockResolvedValue(undefined),
   getFromAddress: vi.fn().mockResolvedValue('noreply@test.com'),
 }))
 
-import { updateEmail, requestEmailChange } from '@/features/settings/Actions/accountActions'
+import { requestEmailChange } from '@/features/settings/Actions/accountActions'
+import { emailVerificationRequired } from '@/lib/email-verification-policy'
 import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
 import { db } from '@/lib/db'
 
@@ -51,42 +54,43 @@ function setupAuth() {
   mockGetCachedMembership.mockResolvedValue(MEMBERSHIP as any)
 }
 
-beforeEach(() => {
+beforeEach(async () => {
   vi.resetAllMocks()
+  // The strict install unless a test says otherwise, and a mail that sends.
+  vi.mocked(emailVerificationRequired).mockResolvedValue(true)
+  const { sendAccountMail } = await import('@/lib/account-mail')
+  vi.mocked(sendAccountMail).mockResolvedValue(undefined)
 })
 
-describe('updateEmail', () => {
-  it('returns Unauthorized when no session', async () => {
-    mockGetCachedSession.mockResolvedValue(null)
-    const result = await updateEmail({ email: 'new@example.com' })
-    expect(result).toEqual({ success: false, error: 'Unauthorized' })
-  })
-
-  it('returns validation error for invalid email', async () => {
-    setupAuth()
-    const result = await updateEmail({ email: 'not-an-email' })
-    expect(result.success).toBe(false)
-    expect(result.error).toContain('email')
-  })
-
-  it('returns error when email is already in use', async () => {
-    setupAuth()
-    mockFindFirst.mockResolvedValue({ id: 'other-user' } as any)
-    const result = await updateEmail({ email: 'taken@example.com' })
-    expect(result).toEqual({ success: false, error: 'Email is already in use' })
-  })
-
-  it('updates email and sets emailVerified=false on success', async () => {
+describe('changing the address where the install does not require verification', () => {
+  it('changes it at once, unverified, and tells the old address', async () => {
+    const { sendAccountMail } = await import('@/lib/account-mail')
     setupAuth()
+    vi.mocked(emailVerificationRequired).mockResolvedValue(false)
     mockFindFirst.mockResolvedValue(null)
+    mockFindUnique.mockResolvedValue({ name: 'Anna', email: 'user@example.com' } as any)
     mockUpdate.mockResolvedValue({} as any)
 
-    const result = await updateEmail({ email: 'new@example.com' })
-    expect(result).toEqual({ success: true, data: { email: 'new@example.com' } })
+    const result = await requestEmailChange({ email: 'new@example.com' })
+
+    expect(result).toEqual({ success: true, data: { sent: false, email: 'new@example.com' } })
     expect(mockUpdate).toHaveBeenCalledWith({
       where: { id: 'user-1' },
       data: { email: 'new@example.com', emailVerified: false },
     })
+    expect(mockUpsert).not.toHaveBeenCalled()
+    expect(sendAccountMail).toHaveBeenCalledWith(
+      expect.objectContaining({ to: 'user@example.com' })
+    )
+  })
+
+  it('still refuses an address somebody else holds', async () => {
+    setupAuth()
+    vi.mocked(emailVerificationRequired).mockResolvedValue(false)
+    mockFindFirst.mockResolvedValue({ id: 'other-user' } as any)
+    const result = await requestEmailChange({ email: 'taken@example.com' })
+    expect(result).toEqual({ success: false, error: 'Email is already in use' })
+    expect(mockUpdate).not.toHaveBeenCalled()
   })
 })
 
@@ -131,7 +135,7 @@ describe('requestEmailChange', () => {
     mockUpsert.mockResolvedValue({} as any)
 
     const result = await requestEmailChange({ email: 'new@example.com' })
-    expect(result).toEqual({ success: true, data: { sent: true } })
+    expect(result).toEqual({ success: true, data: { sent: true, email: 'new@example.com' } })
 
     // Verify upsert was called with the correct identifier pattern
     expect(mockUpsert).toHaveBeenCalledWith(
@@ -149,14 +153,17 @@ describe('requestEmailChange', () => {
     expect(storedValue).toHaveProperty('tokenHash')
     expect(storedValue).toHaveProperty('email', 'new@example.com')
 
-    // Verify email was sent to the NEW address
-    const { sendMail } = await import('@/lib/email')
-    expect(sendMail).toHaveBeenCalledWith(
+    // The link goes to the NEW address, and the current one is told.
+    const { sendAccountMail } = await import('@/lib/account-mail')
+    expect(sendAccountMail).toHaveBeenCalledWith(
       expect.objectContaining({
         to: 'new@example.com',
-        from: 'noreply@test.com',
+        link: expect.objectContaining({ url: expect.stringContaining('confirm-email-change') }),
       })
     )
+    expect(sendAccountMail).toHaveBeenCalledWith(
+      expect.objectContaining({ to: 'user@example.com' })
+    )
   })
 
   it('uses upsert to replace any existing pending change for the user', async () => {

+ 24 - 0
src/__tests__/features/auth/confirm-email-change.test.ts

@@ -11,11 +11,13 @@ vi.mock('@/lib/db', () => ({
     },
     user: {
       findFirst: vi.fn(),
+      findUnique: vi.fn(),
       update: vi.fn(),
     },
     $transaction: vi.fn(),
   },
 }))
+vi.mock('@/lib/account-mail', () => ({ sendAccountMail: vi.fn().mockResolvedValue(undefined) }))
 
 import { GET } from '@/app/api/public/confirm-email-change/route'
 import { db } from '@/lib/db'
@@ -23,6 +25,7 @@ import { db } from '@/lib/db'
 const mockFindVerification = vi.mocked(db.verification.findUnique)
 const mockDeleteVerification = vi.mocked(db.verification.delete)
 const mockFindUser = vi.mocked(db.user.findFirst)
+const mockFindUserById = vi.mocked(db.user.findUnique)
 const mockTransaction = vi.mocked(db.$transaction)
 
 function makeRequest(params: Record<string, string> = {}) {
@@ -160,6 +163,7 @@ describe('GET /api/public/confirm-email-change', () => {
       updatedAt: new Date(),
     } as any)
     mockFindUser.mockResolvedValue(null) // no other user has this email
+    mockFindUserById.mockResolvedValue({ name: 'Anna', email: 'old@example.com' } as any)
     mockTransaction.mockResolvedValue([{}, {}] as any)
 
     const res = await GET(makeRequest({ token, uid: 'user-1' }))
@@ -167,4 +171,24 @@ describe('GET /api/public/confirm-email-change', () => {
     expect(redirectUrl(res)).toContain('emailChanged=true')
     expect(mockTransaction).toHaveBeenCalled()
   })
+
+  it('tells the old address that it no longer signs in', async () => {
+    const { sendAccountMail } = await import('@/lib/account-mail')
+    const { token, tokenHash } = makeTokenAndHash()
+    mockFindVerification.mockResolvedValue({
+      id: 'v-1',
+      identifier: 'email-change:user-1',
+      value: JSON.stringify({ tokenHash, email: 'new@example.com' }),
+      expiresAt: new Date(Date.now() + 60000),
+      createdAt: new Date(),
+      updatedAt: new Date(),
+    } as any)
+    mockFindUser.mockResolvedValue(null)
+    mockFindUserById.mockResolvedValue({ name: 'Anna', email: 'old@example.com' } as any)
+    mockTransaction.mockResolvedValue([{}, {}] as any)
+
+    await GET(makeRequest({ token, uid: 'user-1' }))
+
+    expect(sendAccountMail).toHaveBeenCalledWith(expect.objectContaining({ to: 'old@example.com' }))
+  })
 })

+ 150 - 0
src/__tests__/features/team/app-setup-code-standing.test.ts

@@ -0,0 +1,150 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+/**
+ * Whoever holds an app setup code is signed in as the person it names, so
+ * issuing one borrows that person's standing in the workshop. It may only be
+ * issued looking down the team: never for a peer admin, never for the owner,
+ * always for yourself, and by the owner for anybody.
+ */
+
+vi.mock('@/lib/cached-session', () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}))
+vi.mock('next/headers', () => ({
+  headers: vi.fn().mockResolvedValue(new Headers()),
+  cookies: vi.fn().mockResolvedValue({ set: vi.fn(), get: vi.fn() }),
+}))
+vi.mock('@/lib/audit', () => ({ logAudit: vi.fn().mockResolvedValue(undefined) }))
+vi.mock('@/lib/db', () => ({
+  db: {
+    user: { findUnique: vi.fn() },
+    organizationMember: { findFirst: vi.fn() },
+    technician: { findFirst: vi.fn() },
+    technicianSetupCode: { deleteMany: vi.fn(), create: vi.fn() },
+  },
+}))
+
+import { getCachedMembership, getCachedSession } from '@/lib/cached-session'
+import { db } from '@/lib/db'
+import { createAppSetupCode } from '@/features/team/Actions/createAppSetupCode'
+import { canIssueAppSetupCode } from '@/features/team/Lib/appSetupRules'
+
+type Standing = { role: string; customRoleIsAdmin?: boolean }
+
+/** Signs in `userId` with this standing in org-1. */
+function signIn(userId: string, standing: Standing) {
+  vi.mocked(getCachedSession).mockResolvedValue({ user: { id: userId } } as never)
+  vi.mocked(getCachedMembership).mockResolvedValue({
+    organizationId: 'org-1',
+    role: standing.role,
+    roleId: standing.customRoleIsAdmin ? 'role-admin' : null,
+    customRole: standing.customRoleIsAdmin ? { isAdmin: true, permissions: [] } : null,
+  } as never)
+}
+
+/** The person the code is for, a technician in org-1 with this standing. */
+function target(userId: string, standing: Standing) {
+  vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
+    role: standing.role,
+    customRole: standing.customRoleIsAdmin ? { isAdmin: true } : null,
+    user: { id: userId, name: 'Kari', email: 'kari@example.com' },
+  } as never)
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: false } as never)
+  vi.mocked(db.technician.findFirst).mockResolvedValue({ id: 'tech-1' } as never)
+  vi.mocked(db.technicianSetupCode.deleteMany).mockResolvedValue({ count: 0 } as never)
+  vi.mocked(db.technicianSetupCode.create).mockResolvedValue({} as never)
+})
+
+describe('createAppSetupCode standing', () => {
+  it('refuses an admin setting up the app for the owner', async () => {
+    signIn('admin-1', { role: 'admin' })
+    target('owner-1', { role: 'owner' })
+
+    const result = await createAppSetupCode({ userId: 'owner-1' })
+
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Only the owner can set up the app for the owner.')
+    expect(db.technicianSetupCode.create).not.toHaveBeenCalled()
+  })
+
+  it('refuses an admin setting up the app for another admin', async () => {
+    signIn('admin-1', { role: 'admin' })
+    target('admin-2', { role: 'admin' })
+
+    const result = await createAppSetupCode({ userId: 'admin-2' })
+
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Only the owner can set up the app for an admin.')
+    expect(db.technicianSetupCode.create).not.toHaveBeenCalled()
+  })
+
+  it('counts a custom role with the admin switch as an admin, on both sides', async () => {
+    signIn('admin-1', { role: 'member', customRoleIsAdmin: true })
+    target('admin-2', { role: 'member', customRoleIsAdmin: true })
+
+    const result = await createAppSetupCode({ userId: 'admin-2' })
+
+    expect(result.success).toBe(false)
+    expect(db.technicianSetupCode.create).not.toHaveBeenCalled()
+  })
+
+  it('lets an admin set up the app for a technician', async () => {
+    signIn('admin-1', { role: 'admin' })
+    target('tech-user', { role: 'member' })
+
+    const result = await createAppSetupCode({ userId: 'tech-user' })
+
+    expect(result.success).toBe(true)
+    expect(db.technicianSetupCode.create).toHaveBeenCalledTimes(1)
+  })
+
+  it('lets the owner set up the app for anyone', async () => {
+    signIn('owner-1', { role: 'owner' })
+    for (const standing of [
+      { role: 'admin' },
+      { role: 'member', customRoleIsAdmin: true },
+      { role: 'member' },
+    ]) {
+      target('someone', standing)
+      const result = await createAppSetupCode({ userId: 'someone' })
+      expect(result.success).toBe(true)
+    }
+    expect(db.technicianSetupCode.create).toHaveBeenCalledTimes(3)
+  })
+
+  it('lets anyone set up their own phone', async () => {
+    signIn('admin-1', { role: 'admin' })
+    target('admin-1', { role: 'admin' })
+
+    const result = await createAppSetupCode({ userId: 'admin-1' })
+
+    expect(result.success).toBe(true)
+    expect(db.technicianSetupCode.create).toHaveBeenCalledTimes(1)
+  })
+})
+
+describe('canIssueAppSetupCode', () => {
+  it('keeps a non-admin who can manage settings to their own phone', () => {
+    const caller = { userId: 'u1', role: 'member', isAdmin: false }
+    expect(
+      canIssueAppSetupCode(caller, { userId: 'u2', role: 'member', customRoleIsAdmin: false })
+    ).toEqual({ ok: false, reason: 'Only an owner or admin can set up the app for someone else.' })
+    expect(
+      canIssueAppSetupCode(caller, { userId: 'u1', role: 'member', customRoleIsAdmin: false })
+    ).toEqual({ ok: true })
+  })
+
+  it('treats a super admin as the owner', () => {
+    expect(
+      canIssueAppSetupCode(
+        { userId: 'u1', role: 'super_admin', isAdmin: true },
+        { userId: 'u2', role: 'owner', customRoleIsAdmin: false }
+      )
+    ).toEqual({ ok: true })
+  })
+})

+ 42 - 1
src/__tests__/features/team/invitation-rules.test.ts

@@ -37,6 +37,7 @@ vi.mock('@/lib/features', () => ({
   FeatureGatedError: class extends Error {},
 }))
 
+vi.mock('@/lib/email-verification-policy', () => ({ emailVerificationRequired: vi.fn() }))
 vi.mock('@/lib/db', () => ({
   db: {
     user: { findUnique: vi.fn(), findFirst: vi.fn(), update: vi.fn() },
@@ -71,6 +72,7 @@ import {
 import { sendInvitation } from '@/features/team/Actions/sendInvitation'
 import { getPendingInvitations } from '@/features/team/Actions/getPendingInvitations'
 import { inviteMember } from '@/features/team/Actions/teamActions'
+import { emailVerificationRequired } from '@/lib/email-verification-policy'
 import { acceptInvitation } from '@/features/team/Actions/acceptInvitation'
 
 const mockSession = vi.mocked(getCachedSession)
@@ -98,6 +100,7 @@ function actAsSettingsManager() {
 
 beforeEach(() => {
   vi.clearAllMocks()
+  vi.mocked(emailVerificationRequired).mockResolvedValue(true)
   vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
     id: 'mem-1',
     organizationId: ORG,
@@ -197,7 +200,10 @@ describe('inviteMember agrees with sendInvitation', () => {
     vi.mocked(db.user.findFirst).mockResolvedValueOnce(null)
     const unknown = await inviteMember({ email: 'new@example.com', role: 'member' })
 
-    vi.mocked(db.user.findFirst).mockResolvedValueOnce({ id: 'user-x' } as any)
+    vi.mocked(db.user.findFirst).mockResolvedValueOnce({
+      id: 'user-x',
+      emailVerified: true,
+    } as any)
     vi.mocked(db.organizationMember.findFirst)
       .mockResolvedValueOnce({ id: 'mem-1', organization: { name: 'Org A' } } as any)
       .mockResolvedValueOnce(null)
@@ -209,6 +215,41 @@ describe('inviteMember agrees with sendInvitation', () => {
     expect(db.teamInvitation.create).toHaveBeenCalledTimes(1)
     expect(db.organizationMember.create).toHaveBeenCalledTimes(1)
   })
+
+  it('mails the address rather than seating an account that only typed it', async () => {
+    actAs('admin')
+    vi.mocked(emailVerificationRequired).mockResolvedValue(true)
+
+    // An account exists under the address, but nobody has proved it reaches
+    // them. Joining it directly would seat whoever claimed the address.
+    vi.mocked(db.user.findFirst).mockResolvedValueOnce({
+      id: 'user-squat',
+      emailVerified: false,
+    } as any)
+    const result = await inviteMember({ email: 'anna@garage.test', role: 'member' })
+
+    expect(result.data).toEqual({ invited: true, email: 'anna@garage.test', role: 'member' })
+    expect(db.organizationMember.create).not.toHaveBeenCalled()
+    expect(db.teamInvitation.create).toHaveBeenCalledTimes(1)
+  })
+
+  it('seats an unconfirmed account where the install does not require verification', async () => {
+    actAs('admin')
+    vi.mocked(emailVerificationRequired).mockResolvedValue(false)
+
+    vi.mocked(db.user.findFirst).mockResolvedValueOnce({
+      id: 'user-local',
+      emailVerified: false,
+    } as any)
+    vi.mocked(db.organizationMember.findFirst)
+      .mockResolvedValueOnce({ id: 'mem-1', organization: { name: 'Org A' } } as any)
+      .mockResolvedValueOnce(null)
+    const result = await inviteMember({ email: 'anna@garage.test', role: 'member' })
+
+    expect(result.data).toEqual({ invited: true, email: 'anna@garage.test', role: 'member' })
+    expect(db.organizationMember.create).toHaveBeenCalledTimes(1)
+    expect(db.teamInvitation.create).not.toHaveBeenCalled()
+  })
 })
 
 describe('getPendingInvitations', () => {

+ 157 - 7
src/__tests__/features/technician-account.test.ts

@@ -47,12 +47,28 @@ import { normalizeOrgPhone } from '@/lib/sms'
 import { createTechnicianAccount } from '@/features/team/Actions/createTechnicianAccount'
 import { removeTechnicianAccess } from '@/features/team/Actions/removeTechnicianAccess'
 import { removeMember } from '@/features/team/Actions/teamActions'
+import { PLACEHOLDER_EMAIL_DOMAIN } from '@/features/team/Lib/technicianRole'
 
 const deleteSession = vi.mocked(
   (await auth.$context).internalAdapter.deleteSession as ReturnType<typeof vi.fn>
 )
 const ORG = 'org-a'
 
+/**
+ * Which workshops the person under the number belongs to. The action counts
+ * memberships both here and elsewhere, and the two must answer differently.
+ */
+function memberOf(...orgs: string[]) {
+  vi.mocked(db.organizationMember.count).mockImplementation((async (args: {
+    where: { organizationId?: string | { not: string } }
+  }) => {
+    const where = args.where.organizationId
+    if (typeof where === 'string') return orgs.includes(where) ? 1 : 0
+    if (where && typeof where === 'object') return orgs.filter((o) => o !== where.not).length
+    return orgs.length
+  }) as never)
+}
+
 beforeEach(() => {
   vi.clearAllMocks()
   vi.mocked(getCachedSession).mockResolvedValue({ user: { id: 'desk-1' } } as never)
@@ -62,7 +78,11 @@ beforeEach(() => {
     roleId: null,
     customRole: null,
   } as never)
-  vi.mocked(db.user.findUnique).mockResolvedValue(null as never)
+  // By id: the account a reinstatement reads, invented at a counter. By
+  // email: nobody holds the address yet.
+  vi.mocked(db.user.findUnique).mockImplementation((async (args: {
+    where: { id?: string; email?: string }
+  }) => (args.where.id ? { email: `tech-x@${PLACEHOLDER_EMAIL_DOMAIN}` } : null)) as never)
   vi.mocked(normalizeOrgPhone).mockResolvedValue('+4791234567')
   vi.mocked(db.technician.findFirst).mockResolvedValue(null as never)
   vi.mocked(db.technician.aggregate).mockResolvedValue({ _max: { sortOrder: 2 } } as never)
@@ -72,8 +92,9 @@ beforeEach(() => {
   } as never)
   vi.mocked(db.user.create).mockResolvedValue({ id: 'user-1' } as never)
   vi.mocked(db.role.findFirst).mockResolvedValue({ id: 'role-tech' } as never)
-  vi.mocked(db.organizationMember.count).mockResolvedValue(0 as never)
+  memberOf(ORG)
   vi.mocked(db.technician.findMany).mockResolvedValue([] as never)
+  vi.mocked(db.session.findMany).mockResolvedValue([] as never)
   vi.mocked(db.technicianLoginCode.deleteMany).mockResolvedValue({ count: 0 } as never)
   vi.mocked(db.technicianSetupCode.deleteMany).mockResolvedValue({ count: 0 } as never)
   vi.mocked(db.$transaction).mockImplementation(async (arg: unknown) =>
@@ -175,7 +196,7 @@ describe('creating a technician account at the counter', () => {
       isActive: true,
       userId: 'user-9',
     } as never)
-    vi.mocked(db.organizationMember.count).mockResolvedValue(1 as never)
+    memberOf(ORG)
     vi.mocked(db.technician.update).mockResolvedValue({ id: 'tech-9', userId: 'user-9' } as never)
     vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
       id: 'mem-9',
@@ -199,7 +220,7 @@ describe('creating a technician account at the counter', () => {
       isActive: true,
       userId: 'user-9',
     } as never)
-    vi.mocked(db.organizationMember.count).mockResolvedValue(1 as never)
+    memberOf(ORG)
 
     await createTechnicianAccount({ name: 'Ola', phone: '912 34 567', resolve: 'takeover' })
 
@@ -211,6 +232,26 @@ describe('creating a technician account at the counter', () => {
     expect(db.user.create).toHaveBeenCalled()
   })
 
+  it('will not cut off a number that also opens another workshop', async () => {
+    vi.mocked(db.technician.findFirst).mockResolvedValue({
+      id: 'tech-9',
+      name: 'Kari',
+      isActive: true,
+      userId: 'user-9',
+    } as never)
+    memberOf(ORG, 'org-b')
+
+    const result = await createTechnicianAccount({
+      name: 'Ola',
+      phone: '912 34 567',
+      resolve: 'takeover',
+    })
+
+    expect(result.success).toBe(false)
+    expect(db.user.update).not.toHaveBeenCalled()
+    expect(db.user.create).not.toHaveBeenCalled()
+  })
+
   it('never refuses outright', async () => {
     vi.mocked(db.technician.findFirst).mockResolvedValue({
       id: 'tech-9',
@@ -259,6 +300,76 @@ describe('creating a technician account at the counter', () => {
       )
     })
 
+    it('puts a real address onto the invented one, unverified', async () => {
+      await createTechnicianAccount({
+        name: 'Petter',
+        phone: '912 34 567',
+        email: 'petter@x.test',
+      })
+
+      expect(db.user.update).toHaveBeenCalledWith({
+        where: { id: 'user-old' },
+        data: { name: 'Petter', email: 'petter@x.test', emailVerified: false },
+      })
+    })
+
+    it('will not replace an address the person chose themselves', async () => {
+      vi.mocked(db.user.findUnique).mockImplementation((async (args: { where: { id?: string } }) =>
+        args.where.id ? { email: 'petter@real.test' } : null) as never)
+
+      const result = await createTechnicianAccount({
+        name: 'Petter',
+        phone: '912 34 567',
+        email: 'desk@evil.test',
+      })
+
+      expect(result.success).toBe(false)
+      expect(db.user.update).not.toHaveBeenCalled()
+      expect(db.technician.update).not.toHaveBeenCalled()
+    })
+
+    it('typing their own address again is not a change', async () => {
+      vi.mocked(db.user.findUnique).mockImplementation((async (args: { where: { id?: string } }) =>
+        args.where.id ? { email: 'petter@real.test' } : null) as never)
+
+      const result = await createTechnicianAccount({
+        name: 'Petter',
+        phone: '912 34 567',
+        email: 'petter@real.test',
+      })
+
+      expect(result.success).toBe(true)
+      expect(db.user.update).toHaveBeenCalledWith({
+        where: { id: 'user-old' },
+        data: { name: 'Petter' },
+      })
+    })
+
+    describe('when they also belong to another workshop', () => {
+      beforeEach(() => memberOf('org-b'))
+
+      it('brings them back without touching their account', async () => {
+        const result = await createTechnicianAccount({ name: 'Petter N', phone: '912 34 567' })
+
+        expect(result.success).toBe(true)
+        expect(db.user.update).not.toHaveBeenCalled()
+        expect(db.technician.update).toHaveBeenCalledWith(
+          expect.objectContaining({ data: { isActive: true, name: 'Petter N' } })
+        )
+      })
+
+      it('refuses to give their account a new address from this desk', async () => {
+        const result = await createTechnicianAccount({
+          name: 'Petter',
+          phone: '912 34 567',
+          email: 'desk@evil.test',
+        })
+
+        expect(result.success).toBe(false)
+        expect(db.user.update).not.toHaveBeenCalled()
+      })
+    })
+
     it('keeps their history by reusing the row, not making a second one', async () => {
       await createTechnicianAccount({ name: 'Petter', phone: '912 34 567' })
 
@@ -336,7 +447,7 @@ describe('removing a technician', () => {
       name: 'Ola',
       userId: 'user-1',
     } as never)
-    vi.mocked(db.organizationMember.count).mockResolvedValue(0 as never)
+    memberOf(ORG)
     // The real deleteSession is async and the code chains .catch onto it.
     deleteSession.mockResolvedValue(undefined)
     vi.mocked(db.technician.findMany).mockResolvedValue([{ id: 'tech-1' }] as never)
@@ -359,11 +470,50 @@ describe('removing a technician', () => {
     expect(deleteSession).toHaveBeenCalledWith('tok-2')
   })
 
-  it('leaves sessions alone when they still work at another branch', async () => {
+  it('ends only the sessions this workshop minted when they still work at another branch', async () => {
     vi.mocked(db.organizationMember.count).mockResolvedValue(1 as never)
+    vi.mocked(db.session.findMany).mockResolvedValue([{ token: 'tok-phone' }] as never)
 
     await removeTechnicianAccess({ userId: 'user-1' })
-    expect(deleteSession).not.toHaveBeenCalled()
+
+    // Their ordinary sign-ins open the other branch too and are left alone;
+    // a session bound to this workshop is good for nothing once they are out.
+    expect(db.session.findMany).toHaveBeenCalledWith({
+      where: { userId: 'user-1', organizationId: ORG },
+      select: { token: true, organizationId: true },
+    })
+    expect(deleteSession).toHaveBeenCalledTimes(1)
+    expect(deleteSession).toHaveBeenCalledWith('tok-phone')
+  })
+
+  it('spares the browser you press the button in when it is your own phone', async () => {
+    vi.mocked(getCachedSession).mockResolvedValue({
+      user: { id: 'user-1' },
+      session: { token: 'tok-1' },
+    } as never)
+    vi.mocked(db.session.findMany).mockResolvedValue([
+      { token: 'tok-1', organizationId: null },
+      { token: 'tok-2', organizationId: ORG },
+    ] as never)
+
+    await removeTechnicianAccess({ userId: 'user-1' })
+
+    expect(deleteSession).toHaveBeenCalledTimes(1)
+    expect(deleteSession).toHaveBeenCalledWith('tok-2')
+  })
+
+  it('does not spare the button press when it comes from the phone being revoked', async () => {
+    vi.mocked(getCachedSession).mockResolvedValue({
+      user: { id: 'user-1' },
+      session: { token: 'tok-phone', organizationId: ORG },
+    } as never)
+    vi.mocked(db.session.findMany).mockResolvedValue([
+      { token: 'tok-phone', organizationId: ORG },
+    ] as never)
+
+    await removeTechnicianAccess({ userId: 'user-1' })
+
+    expect(deleteSession).toHaveBeenCalledWith('tok-phone')
   })
 
   it('destroys anything outstanding that could still be redeemed', async () => {

+ 42 - 0
src/__tests__/features/vehicles/note-schema.test.ts

@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest'
+import { createNoteSchema, updateNoteSchema } from '@/features/vehicles/Schema/noteSchema'
+
+/**
+ * A note is HTML the vehicle page renders as HTML. The editor is a client
+ * convenience; the action accepts any string, so the schema has to be where
+ * the string is made safe.
+ */
+describe('vehicle note content', () => {
+  it('keeps what the editor produces', () => {
+    const parsed = createNoteSchema.parse({
+      vehicleId: 'v1',
+      title: 'Brakes',
+      content: '<p>Front pads at <strong>3 mm</strong></p><ul><li>order</li></ul>',
+    })
+    expect(parsed.content).toBe('<p>Front pads at <strong>3 mm</strong></p><ul><li>order</li></ul>')
+  })
+
+  it('strips script, handlers and foreign tags on the way in', () => {
+    const parsed = createNoteSchema.parse({
+      vehicleId: 'v1',
+      title: 'x',
+      content:
+        '<p onclick="steal()">hi</p><img src=x onerror="fetch(\'/api\')"><script>steal()</script>',
+    })
+    expect(parsed.content).toBe('<p>hi</p>steal()')
+    expect(parsed.content).not.toMatch(/onerror|onclick|<img|<script/)
+  })
+
+  it('cleans an edit the same way', () => {
+    const parsed = updateNoteSchema.parse({
+      id: 'n1',
+      content: '<iframe src="javascript:alert(1)"></iframe><em>ok</em>',
+    })
+    expect(parsed.content).toBe('<em>ok</em>')
+  })
+
+  it('does not let an edit move the note to another vehicle', () => {
+    const parsed = updateNoteSchema.parse({ id: 'n1', vehicleId: 'other', title: 't' })
+    expect('vehicleId' in parsed).toBe(false)
+  })
+})

+ 1 - 1
src/__tests__/lib/files/delete-paths.test.ts

@@ -313,7 +313,7 @@ describe('everything at once', () => {
     }
   })
 
-  it('a whole workshop: its own folder, after the rows, never file by file', async () => {
+  it('a whole workshop: its own folder to the trash, after the rows, never file by file', async () => {
     const { deleteOrganizationWithData } = await import('@/lib/delete-user-data')
     await deleteOrganizationWithData(ORG)
     expect(at('removeOrganizationFiles:org-a')).toBeGreaterThan(at('organization.delete'))

+ 51 - 7
src/__tests__/lib/files/manager.test.ts

@@ -313,7 +313,7 @@ describe('discardUnsavedUpload', () => {
 })
 
 describe('removeOrganizationFiles', () => {
-  it('removes the workshop’s folder and its trash under both roots, and nobody else’s', async () => {
+  it('moves the workshop’s folder under both roots into today’s trash, and nobody else’s', async () => {
     await put(roots.current, ORG, 'services', PHOTO)
     await put(roots.legacy, ORG, 'logos', PHOTO)
     await put(roots.current, OTHER, 'services', PHOTO_2)
@@ -322,21 +322,65 @@ describe('removeOrganizationFiles', () => {
       reason: 'test',
       now: NOW,
     })
+    const theirs = await put(roots.current, OTHER, 'services', PHOTO)
+
+    await removeOrganizationFiles(ORG, { now: NOW })
+
+    expect(existsSync(path.join(roots.current, ORG))).toBe(false)
+    expect(existsSync(path.join(roots.legacy, ORG))).toBe(false)
+    expect(existsSync(trashed(roots.current, ORG, 'services', PHOTO))).toBe(true)
+    expect(existsSync(trashed(roots.legacy, ORG, 'logos', PHOTO))).toBe(true)
+    expect(existsSync(theirs)).toBe(true)
+    expect(existsSync(trashed(roots.current, OTHER, 'services', PHOTO_2))).toBe(true)
+  })
+
+  it('merges into what the workshop already has in today’s trash, overwriting nothing', async () => {
+    // Released earlier today: already in the trash under the same name the
+    // folder move would use.
     await put(roots.current, ORG, 'quotes', PHOTO_2)
     await releaseFiles([url(ORG, 'quotes', PHOTO_2)], {
       organizationId: ORG,
       reason: 'test',
       now: NOW,
     })
-    const theirs = await put(roots.current, OTHER, 'services', PHOTO)
+    await writeFile(trashed(roots.current, ORG, 'quotes', PHOTO_2), 'released earlier')
+    await put(roots.current, ORG, 'quotes', PHOTO_2)
+    await put(roots.current, ORG, 'services', PHOTO)
 
-    await removeOrganizationFiles(ORG)
+    await removeOrganizationFiles(ORG, { now: NOW })
 
     expect(existsSync(path.join(roots.current, ORG))).toBe(false)
-    expect(existsSync(path.join(roots.legacy, ORG))).toBe(false)
-    expect(existsSync(path.join(roots.current, '.trash', TODAY, ORG))).toBe(false)
-    expect(existsSync(theirs)).toBe(true)
-    expect(existsSync(trashed(roots.current, OTHER, 'services', PHOTO_2))).toBe(true)
+    expect(existsSync(trashed(roots.current, ORG, 'services', PHOTO))).toBe(true)
+    expect(await readFile(trashed(roots.current, ORG, 'quotes', PHOTO_2), 'utf8')).toBe(
+      'released earlier'
+    )
+    expect(await readFile(trashed(roots.current, ORG, 'quotes', `${PHOTO_2}.1`), 'utf8')).toBe(
+      'bytes'
+    )
+  })
+
+  it('copies the folder then deletes it when the trash is on another disk', async () => {
+    await put(roots.current, ORG, 'services', PHOTO)
+    vi.mocked(rename).mockRejectedValueOnce(
+      Object.assign(new Error('cross-device link'), { code: 'EXDEV' })
+    )
+
+    await removeOrganizationFiles(ORG, { now: NOW })
+
+    expect(existsSync(path.join(roots.current, ORG))).toBe(false)
+    expect(await readFile(trashed(roots.current, ORG, 'services', PHOTO), 'utf8')).toBe('bytes')
+  })
+
+  it('leaves the workshop’s earlier trash for purgeTrash to expire', async () => {
+    const earlier = path.join(roots.current, '.trash', '2026-09-10', ORG, 'services', PHOTO)
+    await mkdir(path.dirname(earlier), { recursive: true })
+    await writeFile(earlier, 'bytes')
+    await put(roots.current, ORG, 'services', PHOTO)
+
+    await removeOrganizationFiles(ORG, { now: NOW })
+
+    expect(existsSync(earlier)).toBe(true)
+    expect(existsSync(trashed(roots.current, ORG, 'services', PHOTO))).toBe(true)
   })
 
   it('refuses an id that is not one, rather than removing the uploads root', async () => {

+ 260 - 0
src/__tests__/lib/reauth.test.ts

@@ -0,0 +1,260 @@
+/**
+ * The proof asked for before deleting an account or a workshop: a password
+ * when the account has one, a two-factor code when it has no password but
+ * two-factor is on, and otherwise a sign-in from the last few minutes. And
+ * that the two delete actions really stop on it, before anything is deleted.
+ */
+import path from 'node:path'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const state = vi.hoisted(() => ({
+  passwordHash: null as string | null,
+  twoFactorEnabled: false,
+  sessionCreatedAt: new Date(),
+  validCode: '123456',
+}))
+
+vi.mock('next/headers', () => ({
+  headers: vi.fn(async () => new Headers()),
+  cookies: vi.fn(async () => ({ get: vi.fn(), delete: vi.fn() })),
+}))
+vi.mock('@/lib/cached-session', () => ({
+  getCachedSession: vi.fn(async () => ({
+    user: { id: 'user-a', email: 'a@x.test', name: 'A' },
+    session: { createdAt: state.sessionCreatedAt },
+  })),
+  getCachedMembership: vi.fn(async () => ({
+    organizationId: 'org-a',
+    role: 'owner',
+    roleId: null,
+    customRole: null,
+  })),
+}))
+vi.mock('@/lib/auth', () => ({
+  auth: {
+    $context: Promise.resolve({
+      password: {
+        verify: vi.fn(
+          async ({ hash, password }: { hash: string; password: string }) =>
+            hash === `hashed:${password}`
+        ),
+      },
+    }),
+    api: {
+      verifyTOTP: vi.fn(async ({ body }: { body: { code: string } }) => {
+        if (body.code !== state.validCode) throw new Error('INVALID_CODE')
+        return { token: 't' }
+      }),
+    },
+  },
+}))
+vi.mock('@/lib/db', () => ({
+  db: {
+    account: {
+      findFirst: vi.fn(async () => (state.passwordHash ? { password: state.passwordHash } : null)),
+    },
+    user: {
+      findUnique: vi.fn(async () => ({
+        twoFactorEnabled: state.twoFactorEnabled,
+        isSuperAdmin: false,
+        email: 'a@x.test',
+      })),
+      delete: vi.fn(),
+    },
+    organizationMember: { findFirst: vi.fn(async () => ({ role: 'owner' })) },
+    organization: { findUnique: vi.fn(async () => ({ name: 'Garage A' })) },
+    teamInvitation: { deleteMany: vi.fn() },
+  },
+}))
+vi.mock('@/lib/delete-user-data', () => ({
+  deleteUserOrganizations: vi.fn(),
+  deleteOrganizationWithData: vi.fn(),
+}))
+vi.mock('@/lib/demo', () => ({ demoGuard: vi.fn(), isDemoMode: vi.fn(() => false) }))
+vi.mock('@/lib/audit', () => ({ logAudit: vi.fn(), auditDetails: vi.fn(() => undefined) }))
+
+import { auth } from '@/lib/auth'
+import { db } from '@/lib/db'
+import { deleteOrganizationWithData, deleteUserOrganizations } from '@/lib/delete-user-data'
+import { REAUTH_ERRORS, reauthErrorMessageKey } from '@/lib/reauth'
+import { reauthRequirement, resetReauthFailures, verifyReauth } from '@/lib/reauth.server'
+import { deleteAccount } from '@/features/settings/Actions/deleteAccount'
+import { deleteWorkshop } from '@/features/team/Actions/deleteWorkshop'
+
+const USER = 'user-a'
+const NOW = Date.UTC(2026, 9, 8, 12)
+const minutesAgo = (minutes: number) => new Date(NOW - minutes * 60_000)
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  resetReauthFailures()
+  state.passwordHash = null
+  state.twoFactorEnabled = false
+  state.sessionCreatedAt = minutesAgo(60)
+  vi.spyOn(console, 'error').mockImplementation(() => undefined)
+})
+
+describe('an account with a password', () => {
+  beforeEach(() => {
+    state.passwordHash = 'hashed:hunter22'
+  })
+
+  it('asks for the password', async () => {
+    expect(await reauthRequirement(USER)).toBe('password')
+  })
+
+  it('passes with the right password, however old the session', async () => {
+    await expect(verifyReauth(USER, { password: 'hunter22' }, 'account', NOW)).resolves.toBe(
+      undefined
+    )
+  })
+
+  it('refuses a wrong password', async () => {
+    await expect(verifyReauth(USER, { password: 'nope' }, 'account', NOW)).rejects.toThrow(
+      REAUTH_ERRORS.wrongPassword
+    )
+  })
+
+  it('refuses a missing password, even on a fresh session', async () => {
+    state.sessionCreatedAt = minutesAgo(1)
+    await expect(verifyReauth(USER, {}, 'account', NOW)).rejects.toThrow(
+      REAUTH_ERRORS.wrongPassword
+    )
+    await expect(verifyReauth(USER, undefined, 'account', NOW)).rejects.toThrow(
+      REAUTH_ERRORS.wrongPassword
+    )
+  })
+
+  it('does not take a two-factor code in place of the password', async () => {
+    state.twoFactorEnabled = true
+    await expect(verifyReauth(USER, { totpCode: '123456' }, 'account', NOW)).rejects.toThrow(
+      REAUTH_ERRORS.wrongPassword
+    )
+  })
+
+  it('stops guessing after five wrong answers, even the right one', async () => {
+    for (let i = 0; i < 5; i++) {
+      await expect(verifyReauth(USER, { password: `guess${i}` }, 'account', NOW)).rejects.toThrow(
+        REAUTH_ERRORS.wrongPassword
+      )
+    }
+    await expect(verifyReauth(USER, { password: 'hunter22' }, 'account', NOW)).rejects.toThrow(
+      REAUTH_ERRORS.tooManyAttempts
+    )
+    // A quarter of an hour later it is open again.
+    await expect(
+      verifyReauth(USER, { password: 'hunter22' }, 'account', NOW + 16 * 60_000)
+    ).resolves.toBe(undefined)
+  })
+})
+
+describe('no password, two-factor on', () => {
+  beforeEach(() => {
+    state.twoFactorEnabled = true
+  })
+
+  it('asks for a code', async () => {
+    expect(await reauthRequirement(USER)).toBe('totp')
+  })
+
+  it('passes with a valid code, checked by Better Auth against the session', async () => {
+    await expect(verifyReauth(USER, { totpCode: '123 456' }, 'account', NOW)).resolves.toBe(
+      undefined
+    )
+    expect(auth.api.verifyTOTP).toHaveBeenCalledWith(
+      expect.objectContaining({ body: { code: '123456' } })
+    )
+  })
+
+  it('refuses a wrong or missing code, even on a fresh session', async () => {
+    state.sessionCreatedAt = minutesAgo(1)
+    await expect(verifyReauth(USER, { totpCode: '000000' }, 'account', NOW)).rejects.toThrow(
+      REAUTH_ERRORS.wrongCode
+    )
+    await expect(verifyReauth(USER, {}, 'account', NOW)).rejects.toThrow(REAUTH_ERRORS.wrongCode)
+  })
+})
+
+describe('no password, no two-factor (Google or passkey only)', () => {
+  it('asks for nothing to type', async () => {
+    expect(await reauthRequirement(USER)).toBe('none')
+  })
+
+  it('passes on a session made in the last ten minutes', async () => {
+    state.sessionCreatedAt = minutesAgo(9)
+    await expect(verifyReauth(USER, {}, 'account', NOW)).resolves.toBe(undefined)
+  })
+
+  it('refuses an older session, and says what to do for each action', async () => {
+    state.sessionCreatedAt = minutesAgo(11)
+    await expect(verifyReauth(USER, {}, 'account', NOW)).rejects.toThrow(
+      REAUTH_ERRORS.signInAgainAccount
+    )
+    await expect(verifyReauth(USER, {}, 'workshop', NOW)).rejects.toThrow(
+      REAUTH_ERRORS.signInAgainWorkshop
+    )
+  })
+
+  it('ignores a password sent anyway', async () => {
+    state.sessionCreatedAt = minutesAgo(30)
+    await expect(verifyReauth(USER, { password: 'anything' }, 'account', NOW)).rejects.toThrow(
+      REAUTH_ERRORS.signInAgainAccount
+    )
+  })
+})
+
+describe('the delete actions', () => {
+  beforeEach(() => {
+    state.passwordHash = 'hashed:hunter22'
+  })
+
+  it('deleteAccount deletes nothing without the password', async () => {
+    const result = await deleteAccount()
+    expect(result).toMatchObject({ success: false, error: REAUTH_ERRORS.wrongPassword })
+    expect(deleteUserOrganizations).not.toHaveBeenCalled()
+    expect(db.user.delete).not.toHaveBeenCalled()
+  })
+
+  it('deleteAccount goes ahead with it', async () => {
+    const result = await deleteAccount({ password: 'hunter22' })
+    expect(result).toMatchObject({ success: true })
+    expect(deleteUserOrganizations).toHaveBeenCalledWith(USER)
+    expect(db.user.delete).toHaveBeenCalledWith({ where: { id: USER } })
+  })
+
+  it('deleteWorkshop deletes nothing without the password, even with the right name', async () => {
+    const result = await deleteWorkshop({ confirmName: 'Garage A', password: 'nope' })
+    expect(result).toMatchObject({ success: false, error: REAUTH_ERRORS.wrongPassword })
+    expect(deleteOrganizationWithData).not.toHaveBeenCalled()
+  })
+
+  it('deleteWorkshop goes ahead with the name and the password', async () => {
+    const result = await deleteWorkshop({ confirmName: 'Garage A', password: 'hunter22' })
+    expect(result).toMatchObject({ success: true })
+    expect(deleteOrganizationWithData).toHaveBeenCalledWith('org-a', USER)
+  })
+})
+
+describe('reauthErrorMessageKey', () => {
+  it('maps each refusal to its settings key, and anything else to null', () => {
+    expect(reauthErrorMessageKey(REAUTH_ERRORS.wrongPassword)).toBe('account.reauthWrongPassword')
+    expect(reauthErrorMessageKey(REAUTH_ERRORS.signInAgainWorkshop)).toBe(
+      'account.reauthSignInAgainWorkshop'
+    )
+    expect(reauthErrorMessageKey('Workshop not found')).toBeNull()
+    expect(reauthErrorMessageKey(undefined)).toBeNull()
+  })
+
+  it('names a key every locale has', async () => {
+    const { readdirSync, readFileSync } = await import('node:fs')
+    const dir = path.join(process.cwd(), 'messages')
+    for (const locale of readdirSync(dir)) {
+      const account = JSON.parse(readFileSync(path.join(dir, locale, 'settings.json'), 'utf8'))
+        .account as Record<string, string>
+      for (const message of Object.values(REAUTH_ERRORS)) {
+        const key = reauthErrorMessageKey(message)?.replace('account.', '') ?? ''
+        expect(account[key], `${locale} ${key}`).toBeTruthy()
+      }
+    }
+  })
+})

+ 220 - 0
src/__tests__/lib/session-scope.test.ts

@@ -0,0 +1,220 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+/**
+ * Sessions a workshop mints without a password (app setup codes, one-time
+ * codes) are bound to that workshop. Nothing the caller sends may point them
+ * at another one: not the app's `x-org-id` header, not the web app's
+ * `active-org-id` cookie, not the switcher.
+ */
+
+vi.mock('@/lib/auth', () => ({ auth: { api: { getSession: vi.fn() } } }))
+vi.mock('@/lib/rate-limit', () => ({ rateLimit: vi.fn(() => null) }))
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+vi.mock('next/headers', () => ({ headers: vi.fn(), cookies: vi.fn() }))
+vi.mock('@/lib/db', () => ({
+  db: {
+    user: { findUnique: vi.fn() },
+    organizationMember: { findFirst: vi.fn() },
+    technician: { findMany: vi.fn() },
+  },
+}))
+
+import { cookies, headers } from 'next/headers'
+import { auth } from '@/lib/auth'
+import { db } from '@/lib/db'
+import { apiOk, withApiAuth } from '@/lib/with-api-auth'
+import { getCachedMembership } from '@/lib/cached-session'
+import { switchOrganization } from '@/features/team/Actions/switchOrganization'
+import {
+  SCOPED_SESSION_MESSAGE,
+  scopedSessionMayCall,
+  sessionOrganizationScope,
+  sessionTokensInHeaders,
+} from '@/lib/session-scope'
+
+const findMember = vi.mocked(db.organizationMember.findFirst)
+
+function membershipIn(organizationId: string, role = 'member') {
+  return { organizationId, role, roleId: null, customRole: null }
+}
+
+function session(organizationId?: string | null) {
+  return {
+    user: { id: 'user-1' },
+    session: { id: 'sess-1', token: 'tok', organizationId: organizationId ?? null },
+  }
+}
+
+/** A membership lookup that answers for whichever workshop it is asked about. */
+function memberOf(...organizationIds: string[]) {
+  findMember.mockImplementation((async (args: {
+    where: { userId: string; organizationId?: string }
+  }) => {
+    const wanted = args.where.organizationId
+    if (wanted) return organizationIds.includes(wanted) ? membershipIn(wanted) : null
+    return organizationIds[0] ? membershipIn(organizationIds[0]) : null
+  }) as never)
+}
+
+function apiRequest(orgHeader?: string) {
+  const h: Record<string, string> = { authorization: 'Bearer token-1' }
+  if (orgHeader) h['x-org-id'] = orgHeader
+  return new Request('https://app.test/api/v1/tech/me', { headers: h })
+}
+
+beforeEach(() => {
+  vi.resetAllMocks()
+  vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: false } as never)
+  vi.mocked(db.technician.findMany).mockResolvedValue([{ id: 'tech-1' }] as never)
+})
+
+describe('withApiAuth and a session bound to a workshop', () => {
+  it('ignores x-org-id and acts in the bound workshop', async () => {
+    vi.mocked(auth.api.getSession).mockResolvedValue(session('org-a') as never)
+    memberOf('org-a', 'org-b')
+
+    let seen: string | undefined
+    const res = await withApiAuth(apiRequest('org-b'), async (ctx) => {
+      seen = ctx.organizationId
+      return apiOk({})
+    })
+
+    expect(res.status).toBe(200)
+    expect(seen).toBe('org-a')
+    for (const [args] of findMember.mock.calls) {
+      expect((args as { where: { organizationId?: string } }).where.organizationId).toBe('org-a')
+    }
+  })
+
+  it('refuses rather than falling back when the person has left the bound workshop', async () => {
+    vi.mocked(auth.api.getSession).mockResolvedValue(session('org-a') as never)
+    memberOf('org-b')
+
+    const handler = vi.fn(async () => apiOk({}))
+    const res = await withApiAuth(apiRequest('org-b'), handler)
+
+    expect(res.status).toBe(401)
+    expect(handler).not.toHaveBeenCalled()
+  })
+
+  it('carries none of the account platform rights', async () => {
+    vi.mocked(auth.api.getSession).mockResolvedValue(session('org-a') as never)
+    vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: true } as never)
+    memberOf('org-a')
+
+    let ctx: { isSuperAdmin: boolean; role: string } | undefined
+    await withApiAuth(apiRequest(), async (c) => {
+      ctx = c
+      return apiOk({})
+    })
+
+    expect(ctx?.isSuperAdmin).toBe(false)
+    expect(ctx?.role).toBe('member')
+  })
+
+  it('still lets an ordinary session choose with x-org-id', async () => {
+    vi.mocked(auth.api.getSession).mockResolvedValue(session(null) as never)
+    memberOf('org-a', 'org-b')
+
+    let seen: string | undefined
+    await withApiAuth(apiRequest('org-b'), async (ctx) => {
+      seen = ctx.organizationId
+      return apiOk({})
+    })
+
+    expect(seen).toBe('org-b')
+  })
+})
+
+describe('getCachedMembership and a session bound to a workshop', () => {
+  function request(orgHeader: string | null, orgCookie: string | null) {
+    vi.mocked(headers).mockResolvedValue(
+      new Headers(orgHeader ? { 'x-org-id': orgHeader } : {}) as never
+    )
+    vi.mocked(cookies).mockResolvedValue({
+      get: (name: string) =>
+        name === 'active-org-id' && orgCookie ? { name, value: orgCookie } : undefined,
+    } as never)
+  }
+
+  it('ignores both the header and the cookie', async () => {
+    vi.mocked(auth.api.getSession).mockResolvedValue(session('org-a') as never)
+    memberOf('org-a', 'org-b')
+    request('org-b', 'org-b')
+
+    const membership = await getCachedMembership('user-1')
+
+    expect(membership?.organizationId).toBe('org-a')
+  })
+
+  it('is no membership at all once the person has left the bound workshop', async () => {
+    vi.mocked(auth.api.getSession).mockResolvedValue(session('org-a') as never)
+    memberOf('org-b')
+    request(null, 'org-b')
+
+    expect(await getCachedMembership('user-1')).toBeNull()
+  })
+
+  it('still honours the cookie for an ordinary session', async () => {
+    vi.mocked(auth.api.getSession).mockResolvedValue(session(null) as never)
+    memberOf('org-a', 'org-b')
+    request(null, 'org-b')
+
+    const membership = await getCachedMembership('user-1')
+
+    expect(membership?.organizationId).toBe('org-b')
+  })
+})
+
+describe('switchOrganization and a session bound to a workshop', () => {
+  it('refuses to switch', async () => {
+    vi.mocked(headers).mockResolvedValue(new Headers() as never)
+    const set = vi.fn()
+    vi.mocked(cookies).mockResolvedValue({ set, get: vi.fn() } as never)
+    vi.mocked(auth.api.getSession).mockResolvedValue(session('org-a') as never)
+    memberOf('org-a', 'org-b')
+
+    const result = await switchOrganization('org-b')
+
+    expect(result).toEqual({ success: false, error: SCOPED_SESSION_MESSAGE })
+    expect(set).not.toHaveBeenCalled()
+  })
+})
+
+describe('session-scope helpers', () => {
+  it('reads the scope off a getSession result', () => {
+    expect(sessionOrganizationScope(session('org-a'))).toBe('org-a')
+    expect(sessionOrganizationScope(session(null))).toBeNull()
+    expect(sessionOrganizationScope({ session: {} })).toBeNull()
+    expect(sessionOrganizationScope(null)).toBeNull()
+  })
+
+  it('lets a bound session read itself and sign out, and nothing else', () => {
+    expect(scopedSessionMayCall('/get-session')).toBe(true)
+    expect(scopedSessionMayCall('/sign-out')).toBe(true)
+    for (const path of [
+      '/list-sessions',
+      '/passkey/generate-register-options',
+      '/change-password',
+      '/update-user',
+      '/two-factor/enable',
+      undefined,
+    ]) {
+      expect(scopedSessionMayCall(path)).toBe(false)
+    }
+  })
+
+  it('finds the token in a bearer header and in the session cookie, signed or not', () => {
+    const cookieName = 'better-auth.session_token'
+    const h = new Headers({
+      authorization: 'Bearer abc123.c2lnbmF0dXJl',
+      cookie: `other=1; ${cookieName}=def456.c2ln%3D; tail=2`,
+    })
+    expect(sessionTokensInHeaders(h, cookieName).sort()).toEqual(['abc123', 'def456'])
+    expect(
+      sessionTokensInHeaders(new Headers({ authorization: 'Bearer plain' }), cookieName)
+    ).toEqual(['plain'])
+    expect(sessionTokensInHeaders(new Headers(), cookieName)).toEqual([])
+    expect(sessionTokensInHeaders(undefined, cookieName)).toEqual([])
+  })
+})

+ 94 - 0
src/__tests__/lib/video-sniff.test.ts

@@ -0,0 +1,94 @@
+import { describe, expect, it } from 'vitest'
+import {
+  ffmpegInputFormat,
+  isDeclaredVideo,
+  sniffDeclaredVideo,
+  sniffVideoContainer,
+} from '@/lib/video-sniff'
+
+/** The opening box of an ISO base media file: size, "ftyp", brand, version. */
+function mp4Header(brand = 'isom'): Uint8Array {
+  const bytes = new Uint8Array(32)
+  bytes.set([0x00, 0x00, 0x00, 0x20], 0)
+  bytes.set(new TextEncoder().encode(`ftyp${brand}`), 4)
+  return bytes
+}
+
+/** The EBML header WebM and Matroska files open with. */
+function webmHeader(): Uint8Array {
+  const bytes = new Uint8Array(32)
+  bytes.set([0x1a, 0x45, 0xdf, 0xa3, 0x9f, 0x42, 0x86, 0x81, 0x01], 0)
+  return bytes
+}
+
+const text = (value: string) => new TextEncoder().encode(value)
+
+const PLAYLIST = text(
+  '#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:0\n#EXTINF:10.0,\nfile:///proc/self/environ\n#EXT-X-ENDLIST\n'
+)
+const CONCAT = text("ffconcat version 1.0\nfile '/proc/self/environ'\n")
+
+describe('sniffVideoContainer', () => {
+  it('reads an MP4 from the ftyp box at byte 4', () => {
+    expect(sniffVideoContainer(mp4Header())).toBe('mp4')
+  })
+
+  it('reads a QuickTime file the same way', () => {
+    expect(sniffVideoContainer(mp4Header('qt  '))).toBe('mp4')
+  })
+
+  it('reads a WebM from the EBML header', () => {
+    expect(sniffVideoContainer(webmHeader())).toBe('webm')
+  })
+
+  it('refuses an HLS playlist and a concat script', () => {
+    expect(sniffVideoContainer(PLAYLIST)).toBeNull()
+    expect(sniffVideoContainer(CONCAT)).toBeNull()
+  })
+
+  it('refuses an empty buffer and one too short to hold a header', () => {
+    expect(sniffVideoContainer(new Uint8Array(0))).toBeNull()
+    expect(sniffVideoContainer(mp4Header().slice(0, 8))).toBeNull()
+  })
+})
+
+describe('sniffDeclaredVideo', () => {
+  it('accepts bytes that are the container the type promises', () => {
+    expect(sniffDeclaredVideo('video/mp4', mp4Header())).toBe('mp4')
+    expect(sniffDeclaredVideo('video/quicktime', mp4Header('qt  '))).toBe('mp4')
+    expect(sniffDeclaredVideo('video/webm', webmHeader())).toBe('webm')
+  })
+
+  it('refuses a playlist declared as video/mp4', () => {
+    expect(sniffDeclaredVideo('video/mp4', PLAYLIST)).toBeNull()
+  })
+
+  it('refuses a container other than the one declared', () => {
+    expect(sniffDeclaredVideo('video/mp4', webmHeader())).toBeNull()
+    expect(sniffDeclaredVideo('video/webm', mp4Header())).toBeNull()
+  })
+
+  it('refuses a video type no route accepts', () => {
+    expect(sniffDeclaredVideo('video/x-matroska', webmHeader())).toBeNull()
+    expect(sniffDeclaredVideo('application/vnd.apple.mpegurl', PLAYLIST)).toBeNull()
+  })
+
+  it('refuses an empty buffer', () => {
+    expect(sniffDeclaredVideo('video/mp4', new Uint8Array(0))).toBeNull()
+  })
+})
+
+describe('isDeclaredVideo', () => {
+  it('is true for video types only', () => {
+    expect(isDeclaredVideo('video/mp4')).toBe(true)
+    expect(isDeclaredVideo('image/png')).toBe(false)
+    expect(isDeclaredVideo('text/plain')).toBe(false)
+  })
+})
+
+describe('ffmpegInputFormat', () => {
+  it('pins the demuxer that matches the sniffed container', () => {
+    expect(ffmpegInputFormat('mp4')).toBe('mov')
+    expect(ffmpegInputFormat('webm')).toBe('matroska')
+  })
+})

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

@@ -2,6 +2,7 @@ import { redirect } from 'next/navigation'
 import { getTranslations } from 'next-intl/server'
 import { getCachedSession } from '@/lib/cached-session'
 import { db } from '@/lib/db'
+import { sessionOrganizationScope } from '@/lib/session-scope'
 import { PageHeader } from '@/components/page-header'
 import { AdminNav } from './admin-nav'
 
@@ -15,7 +16,8 @@ export default async function AdminLayout({ children }: { children: React.ReactN
     select: { isSuperAdmin: true },
   })
 
-  if (!user?.isSuperAdmin) redirect('/')
+  // Not on a session a workshop minted for a phone (lib/session-scope.ts).
+  if (!user?.isSuperAdmin || sessionOrganizationScope(session)) redirect('/')
 
   const t = await getTranslations('admin')
 

+ 8 - 15
src/app/(authenticated)/settings/account/account-settings.tsx

@@ -35,7 +35,7 @@ import {
 } from 'lucide-react'
 import { PasskeySettings } from '@/features/settings/Components/passkey-settings'
 import { QRCodeSVG } from 'qrcode.react'
-import { updateEmail, requestEmailChange } from '@/features/settings/Actions/accountActions'
+import { requestEmailChange } from '@/features/settings/Actions/accountActions'
 import { useCooldown } from '@/hooks/use-cooldown'
 import { useRouter } from 'next/navigation'
 import { useTranslations } from 'next-intl'
@@ -201,21 +201,14 @@ export function AccountSettings({
         return
       }
       if (email !== session?.user?.email) {
-        if (emailVerificationRequired) {
-          const emailResult = await requestEmailChange({ email })
-          if (!emailResult.success) {
-            toast.error(emailResult.error || t('account.failedUpdateEmail'))
-            return
-          }
-          toast.success(t('account.emailChangeSent'))
-        } else {
-          const emailResult = await updateEmail({ email })
-          if (!emailResult.success) {
-            toast.error(emailResult.error || t('account.failedUpdateEmail'))
-            return
-          }
-          toast.success(t('account.profileUpdated'))
+        const emailResult = await requestEmailChange({ email })
+        if (!emailResult.success) {
+          toast.error(emailResult.error || t('account.failedUpdateEmail'))
+          return
         }
+        toast.success(
+          emailResult.data?.sent ? t('account.emailChangeSent') : t('account.profileUpdated')
+        )
       } else {
         toast.success(t('account.profileUpdated'))
       }

+ 49 - 8
src/app/(authenticated)/settings/data/data-settings.tsx

@@ -43,6 +43,8 @@ import { deleteContent } from '@/features/settings/Actions/deleteContent'
 import { deleteWorkshop } from '@/features/team/Actions/deleteWorkshop'
 import { deleteAccount } from '@/features/settings/Actions/deleteAccount'
 import { signOut } from '@/lib/auth-client'
+import type { ReauthRequirement } from '@/lib/reauth'
+import { ReauthField } from './reauth-field'
 import { toast } from 'sonner'
 import { useRouter } from 'next/navigation'
 
@@ -124,12 +126,15 @@ export function DataSettings({
   workshopName = '',
   isOwner = false,
   hasSampleData = false,
+  reauth = 'password',
 }: {
   contentCounts: ContentCounts
   lastBackupAt?: string | null
   workshopName?: string
   isOwner?: boolean
   hasSampleData?: boolean
+  /** What the delete dialogs must ask for before the server will delete. */
+  reauth?: ReauthRequirement
 }) {
   const t = useTranslations('settings')
   const format = useFormatter()
@@ -250,18 +255,35 @@ export function DataSettings({
   const [accountDialogOpen, setAccountDialogOpen] = useState(false)
   const [accountConfirmText, setAccountConfirmText] = useState('')
   const [deletingAccount, setDeletingAccount] = useState(false)
+  // The password or two-factor code the delete dialogs ask for, and the
+  // server's refusal, shown in the dialog rather than a toast so it sits next
+  // to the field it is about.
+  const [reauthValue, setReauthValue] = useState('')
+  const [reauthError, setReauthError] = useState<string | null>(null)
+  const reauthReady = reauth === 'none' || reauthValue.trim() !== ''
+  const reauthInput =
+    reauth === 'password'
+      ? { password: reauthValue }
+      : reauth === 'totp'
+        ? { totpCode: reauthValue }
+        : {}
+  const resetReauth = () => {
+    setReauthValue('')
+    setReauthError(null)
+  }
 
   const handleDeleteWorkshop = async () => {
-    if (workshopConfirmText !== workshopName) return
+    if (workshopConfirmText !== workshopName || !reauthReady) return
     setDeletingWorkshop(true)
+    setReauthError(null)
     try {
-      const result = await deleteWorkshop({ confirmName: workshopConfirmText })
+      const result = await deleteWorkshop({ confirmName: workshopConfirmText, ...reauthInput })
       if (result.success) {
         toast.success(t('account.deleteWorkshopSuccess'))
         // The layout resolves the next membership, or onboarding if none left.
         window.location.href = '/'
       } else {
-        toast.error(result.error || t('account.deleteWorkshopFailed'))
+        setReauthError(result.error || t('account.deleteWorkshopFailed'))
         setDeletingWorkshop(false)
       }
     } catch {
@@ -271,15 +293,16 @@ export function DataSettings({
   }
 
   const handleDeleteAccount = async () => {
-    if (accountConfirmText !== 'delete me') return
+    if (accountConfirmText !== 'delete me' || !reauthReady) return
     setDeletingAccount(true)
+    setReauthError(null)
     try {
-      const result = await deleteAccount()
+      const result = await deleteAccount(reauthInput)
       if (result.success) {
         await signOut()
         router.push('/auth/sign-in')
       } else {
-        toast.error(result.error || t('account.failedDeleteAccount'))
+        setReauthError(result.error || t('account.failedDeleteAccount'))
         setDeletingAccount(false)
       }
     } catch {
@@ -793,6 +816,7 @@ export function DataSettings({
                   variant="destructive"
                   onClick={() => {
                     setWorkshopConfirmText('')
+                    resetReauth()
                     setWorkshopDialogOpen(true)
                   }}
                 >
@@ -815,6 +839,7 @@ export function DataSettings({
               variant="destructive"
               onClick={() => {
                 setAccountConfirmText('')
+                resetReauth()
                 setAccountDialogOpen(true)
               }}
             >
@@ -849,6 +874,14 @@ export function DataSettings({
               placeholder={workshopName}
               autoComplete="off"
             />
+            <ReauthField
+              id="delete-workshop-reauth"
+              requirement={reauth}
+              value={reauthValue}
+              onChange={setReauthValue}
+              onSubmit={handleDeleteWorkshop}
+              error={reauthError}
+            />
           </div>
           <DialogFooter>
             <Button
@@ -861,7 +894,7 @@ export function DataSettings({
             <Button
               variant="destructive"
               onClick={handleDeleteWorkshop}
-              disabled={workshopConfirmText !== workshopName || deletingWorkshop}
+              disabled={workshopConfirmText !== workshopName || !reauthReady || deletingWorkshop}
             >
               {deletingWorkshop ? (
                 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -897,6 +930,14 @@ export function DataSettings({
               placeholder={t('account.deleteAccountConfirmPhrase')}
               autoComplete="off"
             />
+            <ReauthField
+              id="delete-account-reauth"
+              requirement={reauth}
+              value={reauthValue}
+              onChange={setReauthValue}
+              onSubmit={handleDeleteAccount}
+              error={reauthError}
+            />
           </div>
           <DialogFooter>
             <Button
@@ -909,7 +950,7 @@ export function DataSettings({
             <Button
               variant="destructive"
               onClick={handleDeleteAccount}
-              disabled={accountConfirmText !== 'delete me' || deletingAccount}
+              disabled={accountConfirmText !== 'delete me' || !reauthReady || deletingAccount}
             >
               {deletingAccount ? (
                 <Loader2 className="mr-2 h-4 w-4 animate-spin" />

+ 4 - 0
src/app/(authenticated)/settings/data/page.tsx

@@ -3,6 +3,7 @@ import { getContentCounts } from '@/features/settings/Actions/deleteContent'
 import { getBackupHeartbeat } from '@/lib/backup-heartbeat'
 import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
 import { db } from '@/lib/db'
+import { reauthRequirement } from '@/lib/reauth.server'
 import {
   SAMPLE_DATA_IDS_KEY,
   hasAnySampleIds,
@@ -17,6 +18,8 @@ export default async function DataSettingsPage() {
   const session = await getCachedSession()
   const membership = session?.user?.id ? await getCachedMembership(session.user.id) : null
   const isOwner = membership?.role === 'owner'
+  // Which proof the delete dialogs ask for; the actions check it again.
+  const reauth = session?.user?.id ? await reauthRequirement(session.user.id) : 'none'
   const [org, sampleRow] = membership
     ? await Promise.all([
         db.organization.findUnique({
@@ -57,6 +60,7 @@ export default async function DataSettingsPage() {
       workshopName={org?.name ?? ''}
       isOwner={isOwner}
       hasSampleData={hasSampleData}
+      reauth={reauth}
     />
   )
 }

+ 84 - 0
src/app/(authenticated)/settings/data/reauth-field.tsx

@@ -0,0 +1,84 @@
+'use client'
+
+import { useTranslations } from 'next-intl'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import {
+  REAUTH_FRESH_SESSION_MINUTES,
+  type ReauthRequirement,
+  reauthErrorMessageKey,
+} from '@/lib/reauth'
+
+/**
+ * The proof a destructive dialog asks for before the server will go ahead:
+ * the password, a two-factor code, or (with neither on the account) a note
+ * that only a recent sign-in will do. The server decides; this only shows the
+ * field it is going to check, and its refusal.
+ */
+export function ReauthField({
+  id,
+  requirement,
+  value,
+  onChange,
+  onSubmit,
+  error,
+}: {
+  id: string
+  requirement: ReauthRequirement
+  value: string
+  onChange: (value: string) => void
+  onSubmit?: () => void
+  error: string | null
+}) {
+  const t = useTranslations('settings')
+  const errorKey = reauthErrorMessageKey(error ?? undefined)
+
+  return (
+    <div className="space-y-2">
+      {requirement === 'password' && (
+        <>
+          <Label htmlFor={id}>{t('account.reauthPasswordLabel')}</Label>
+          <Input
+            id={id}
+            type="password"
+            value={value}
+            onChange={(e) => onChange(e.target.value)}
+            placeholder={t('account.enterPassword')}
+            autoComplete="current-password"
+            onKeyDown={(e) => {
+              if (e.key === 'Enter') onSubmit?.()
+            }}
+          />
+        </>
+      )}
+      {requirement === 'totp' && (
+        <>
+          <Label htmlFor={id}>{t('account.reauthTotpLabel')}</Label>
+          <Input
+            id={id}
+            type="text"
+            inputMode="numeric"
+            value={value}
+            onChange={(e) => onChange(e.target.value)}
+            placeholder={t('account.authCodePlaceholder')}
+            autoComplete="one-time-code"
+            className="tracking-widest"
+            onKeyDown={(e) => {
+              if (e.key === 'Enter') onSubmit?.()
+            }}
+          />
+        </>
+      )}
+      {requirement === 'none' && (
+        <p className="text-sm text-muted-foreground">
+          {t('account.reauthFreshSessionHint', { minutes: REAUTH_FRESH_SESSION_MINUTES })}
+        </p>
+      )}
+      {error && (
+        <p role="alert" className="text-sm font-medium text-destructive">
+          {errorKey ? t(errorKey) : error}
+        </p>
+      )}
+    </div>
+  )
+}

+ 2 - 1
src/app/(authenticated)/vehicles/[id]/vehicle-detail-client.tsx

@@ -105,6 +105,7 @@ import { quoteStatusLabel } from '@/features/quotes/Lib/quoteStatus'
 import { useServiceType } from '@/components/service-type-context'
 import { formatTypeKey } from '@/features/vehicles/Lib/typeKey'
 import { useTypeKeyEnabled } from '@/components/type-key-context'
+import { sanitizeHtml } from '@/lib/sanitize-html'
 
 interface CustomerOption {
   id: string
@@ -1787,7 +1788,7 @@ export function VehicleDetailClient({
           </DialogHeader>
           <div
             className="notes-content max-h-[60vh] overflow-y-auto text-sm break-all"
-            dangerouslySetInnerHTML={{ __html: selectedNote?.content ?? '' }}
+            dangerouslySetInnerHTML={{ __html: sanitizeHtml(selectedNote?.content ?? '') }}
           />
           <div className="flex justify-end pt-2">
             <Button

+ 21 - 1
src/app/api/protected/upload/quote-files/route.ts

@@ -5,6 +5,8 @@ import { writeFile, mkdir } from 'fs/promises'
 import path from 'path'
 import crypto from 'crypto'
 import { uploadsRoot } from '@/lib/upload-root'
+import { assertContentLength } from '@/lib/backup/zip-guard'
+import { isDeclaredVideo, NOT_A_VIDEO_MESSAGE, sniffDeclaredVideo } from '@/lib/video-sniff'
 
 const ALLOWED_TYPES = [
   'image/jpeg',
@@ -20,6 +22,10 @@ const ALLOWED_TYPES = [
 
 const MAX_SIZE = 10 * 1024 * 1024 // 10MB
 
+// The multipart framing around the file adds a little to the body. The
+// margin keeps a file just under the cap from being refused for it.
+const MAX_BODY = MAX_SIZE + 1024 * 1024
+
 export async function POST(request: NextRequest) {
   try {
     const ctx = await getAuthContext()
@@ -28,6 +34,13 @@ export async function POST(request: NextRequest) {
       return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
     }
 
+    // Refused from the header, before the body is buffered into memory.
+    try {
+      assertContentLength(request, MAX_BODY)
+    } catch {
+      return NextResponse.json({ error: 'File size must be under 10MB' }, { status: 413 })
+    }
+
     const formData = await request.formData()
     const file = formData.get('file') as File | null
 
@@ -48,13 +61,20 @@ export async function POST(request: NextRequest) {
       return NextResponse.json({ error: 'File size must be under 10MB' }, { status: 400 })
     }
 
+    const bytes = new Uint8Array(await file.arrayBuffer())
+
+    // A video is stored and served as one only when its bytes are the
+    // container its declared type promises.
+    if (isDeclaredVideo(file.type) && !sniffDeclaredVideo(file.type, bytes)) {
+      return NextResponse.json({ error: NOT_A_VIDEO_MESSAGE }, { status: 400 })
+    }
+
     const ext = extensionForType(file.type)
     const filename = `${crypto.randomUUID()}.${ext}`
     const uploadDir = path.join(uploadsRoot(), ctx.organizationId, 'quotes')
 
     await mkdir(uploadDir, { recursive: true })
 
-    const bytes = new Uint8Array(await file.arrayBuffer())
     await writeFile(path.join(uploadDir, filename), bytes)
 
     return NextResponse.json({

+ 87 - 22
src/app/api/protected/upload/service-files/route.ts

@@ -8,6 +8,14 @@ import crypto from 'crypto'
 import { execFile } from 'child_process'
 import { promisify } from 'util'
 import { uploadsRoot } from '@/lib/upload-root'
+import { assertContentLength } from '@/lib/backup/zip-guard'
+import {
+  ffmpegInputFormat,
+  isDeclaredVideo,
+  NOT_A_VIDEO_MESSAGE,
+  sniffDeclaredVideo,
+  type VideoContainer,
+} from '@/lib/video-sniff'
 
 const execFileAsync = promisify(execFile)
 
@@ -24,15 +32,41 @@ const ALLOWED_TYPES = [
 ]
 
 const MAX_SIZE = 500 * 1024 * 1024 // 500MB
-const VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/quicktime']
 
-async function compressVideo(inputPath: string, outputPath: string): Promise<boolean> {
+// The multipart framing around the file adds a little to the body. The
+// margin keeps a file just under the cap from being refused for it.
+const MAX_BODY = MAX_SIZE + 1024 * 1024
+
+/**
+ * Re-encodes a video whose container has already been checked from its
+ * bytes. The input demuxer is pinned rather than guessed, so a playlist or
+ * concat script can never be read as one, and only local files may be
+ * opened. Subtitle and data streams are dropped and only the first video
+ * and audio streams are kept, so nothing but picture and sound reaches the
+ * output the uploader gets back.
+ */
+async function compressVideo(
+  inputPath: string,
+  container: VideoContainer,
+  outputPath: string
+): Promise<boolean> {
   try {
     await execFileAsync(
       'ffmpeg',
       [
+        '-nostdin',
+        '-protocol_whitelist',
+        'file,crypto',
+        '-f',
+        ffmpegInputFormat(container),
         '-i',
         inputPath,
+        '-map',
+        '0:v:0',
+        '-map',
+        '0:a:0?',
+        '-sn',
+        '-dn',
         '-vf',
         'scale=-2:720', // Scale to 720p, keep aspect ratio
         '-c:v',
@@ -65,6 +99,13 @@ export async function POST(request: NextRequest) {
     return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
   }
 
+  // Refused from the header, before the body is buffered into memory.
+  try {
+    assertContentLength(request, MAX_BODY)
+  } catch {
+    return NextResponse.json({ error: 'File size must be under 500MB' }, { status: 413 })
+  }
+
   const formData = await request.formData()
   const file = formData.get('file') as File | null
 
@@ -83,31 +124,55 @@ export async function POST(request: NextRequest) {
     return NextResponse.json({ error: 'File size must be under 500MB' }, { status: 400 })
   }
 
-  const isVideo = VIDEO_TYPES.includes(file.type)
-  const ext = isVideo ? 'mp4' : extensionForType(file.type)
-  const filename = `${crypto.randomUUID()}.${ext}`
+  const bytes = new Uint8Array(await file.arrayBuffer())
+
+  // The declared type only says the client hopes this is a video. It is
+  // treated as one when the bytes agree, and refused when they do not.
+  let container: VideoContainer | null = null
+  if (isDeclaredVideo(file.type)) {
+    container = sniffDeclaredVideo(file.type, bytes)
+    if (!container) {
+      return NextResponse.json({ error: NOT_A_VIDEO_MESSAGE }, { status: 400 })
+    }
+  }
+
+  // A video is re-encoded to MP4 when ffmpeg is there and manages it;
+  // otherwise it is kept as it came, under the container it really holds.
+  let ext = container ? 'mp4' : extensionForType(file.type)
+  const baseName = crypto.randomUUID()
+  let filename = `${baseName}.${ext}`
   const uploadDir = path.join(uploadsRoot(), ctx.organizationId, 'services')
 
   await mkdir(uploadDir, { recursive: true })
 
-  const bytes = new Uint8Array(await file.arrayBuffer())
-  const finalPath = path.join(uploadDir, filename)
-
-  if (isVideo) {
-    // Write original to temp file, compress, then clean up. The extension
-    // is the uploaded name's only if it is plainly one: a name is the
-    // client's word, and `a.x/../../y` would otherwise put the file anywhere.
-    const nameExt = file.name.split('.').pop() ?? ''
-    const tempExt = /^[A-Za-z0-9]{1,8}$/.test(nameExt) ? nameExt : 'mp4'
-    const tempFilename = `${crypto.randomUUID()}_orig.${tempExt}`
-    const tempPath = path.join(uploadDir, tempFilename)
-    await writeFile(tempPath, bytes)
+  let finalPath = path.join(uploadDir, filename)
+  let storedType = file.type
 
-    const compressed = await compressVideo(tempPath, finalPath)
-    await discardUnsavedUpload(ctx.organizationId, 'services', tempFilename)
+  if (container) {
+    // The original goes to a temporary file named for the container the
+    // bytes hold, never the client's name, and is always removed after.
+    const tempFilename = `${crypto.randomUUID()}_orig.${container}`
+    const tempPath = path.join(uploadDir, tempFilename)
+    let compressed = false
+    try {
+      await writeFile(tempPath, bytes)
+      compressed = await compressVideo(tempPath, container, finalPath)
+    } finally {
+      await discardUnsavedUpload(ctx.organizationId, 'services', tempFilename)
+    }
 
-    if (!compressed) {
-      // Fallback: save original as-is
+    if (compressed) {
+      storedType = 'video/mp4'
+    } else {
+      // No ffmpeg on this install, or a take it could not read. The bytes
+      // were checked to be the container they claim before anything ran,
+      // and nothing has touched them since, so they are kept as they are:
+      // a larger file, named and served as what it is, never as MP4.
+      await discardUnsavedUpload(ctx.organizationId, 'services', filename)
+      ext = container
+      filename = `${baseName}.${ext}`
+      finalPath = path.join(uploadDir, filename)
+      storedType = `video/${container}`
       await writeFile(finalPath, bytes)
     }
   } else {
@@ -119,7 +184,7 @@ export async function POST(request: NextRequest) {
   return NextResponse.json({
     url: `/api/protected/files/${ctx.organizationId}/services/${filename}`,
     fileName: file.name,
-    fileType: isVideo ? 'video/mp4' : file.type,
+    fileType: storedType,
     fileSize: finalStat.size,
   })
 }

+ 12 - 5
src/app/api/protected/ws/route.ts

@@ -257,7 +257,12 @@ export function UPGRADE(ws: WebSocket, _server: unknown, _request: IncomingMessa
 
       const session = await db.session.findUnique({
         where: { token: sessionToken },
-        select: { userId: true, expiresAt: true, user: { select: { name: true, email: true } } },
+        select: {
+          userId: true,
+          expiresAt: true,
+          organizationId: true,
+          user: { select: { name: true, email: true } },
+        },
       })
 
       if (!session || session.expiresAt < new Date()) {
@@ -268,11 +273,13 @@ export function UPGRADE(ws: WebSocket, _server: unknown, _request: IncomingMessa
         return
       }
 
-      const activeOrgId = cookieStore.get('active-org-id')?.value
-
       // Decided where the rest of the app decides it, so the socket and the
-      // pages can never disagree about which workshop this person is in.
-      const membership = await resolveMembership(session.userId, activeOrgId)
+      // pages can never disagree about which workshop this person is in. A
+      // session bound to one workshop (lib/session-scope.ts) is in that one
+      // whatever the cookie says.
+      const membership = session.organizationId
+        ? await resolveMembership(session.userId, session.organizationId, { bound: true })
+        : await resolveMembership(session.userId, cookieStore.get('active-org-id')?.value)
 
       if (!membership) {
         trace(`socket refused: ${session.user?.name ?? session.userId} is in no workshop`)

+ 24 - 0
src/app/api/public/confirm-email-change/route.ts

@@ -64,6 +64,11 @@ export async function GET(request: NextRequest) {
       return NextResponse.redirect(new URL('/settings/account?error=email-taken', request.url))
     }
 
+    const before = await db.user.findUnique({
+      where: { id: uid },
+      select: { name: true, email: true },
+    })
+
     // Update the user's email and mark as verified
     await db.$transaction([
       db.user.update({
@@ -78,6 +83,25 @@ export async function GET(request: NextRequest) {
       }),
     ])
 
+    // The address that just stopped being the account's is told so. It is
+    // the last mail it will get from this account, and the one that matters
+    // if the change was not theirs.
+    if (before?.email && before.email !== stored.email) {
+      const { sendAccountMail } = await import('@/lib/account-mail')
+      await sendAccountMail({
+        to: before.email,
+        subject: 'Your Torqvoice email was changed',
+        name: before.name,
+        paragraphs: [
+          'Your Torqvoice account has moved to a different email address. This address no longer signs in to it.',
+        ],
+        notes: [
+          'If this was you, there is nothing to do.',
+          'If it was not, contact your workshop owner or support straight away.',
+        ],
+      }).catch(() => undefined)
+    }
+
     return NextResponse.redirect(new URL('/settings/account?emailChanged=true', request.url))
   } catch {
     return NextResponse.redirect(new URL('/settings/account?error=unexpected', request.url))

+ 23 - 2
src/app/api/v1/tech/jobs/[id]/attachments/route.ts

@@ -6,6 +6,8 @@ import { getFeatures, type PlanFeatures } from '@/lib/features'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
 import { uploadsRoot } from '@/lib/upload-root'
+import { assertContentLength } from '@/lib/backup/zip-guard'
+import { isDeclaredVideo, sniffDeclaredVideo } from '@/lib/video-sniff'
 
 /**
  * Attaches a photo or video shot in the bay to a job.
@@ -39,6 +41,10 @@ const ALLOWED = new Map<string, string>([
  */
 const MAX_BYTES = 60 * 1024 * 1024
 
+// The multipart framing around the file adds a little to the body. The
+// margin keeps a file just under the cap from being refused for it.
+const MAX_BODY = MAX_BYTES + 1024 * 1024
+
 const CATEGORY_LIMIT: Record<string, keyof PlanFeatures | undefined> = {
   image: 'maxImagesPerService',
   diagnostic: 'maxDiagnosticsPerService',
@@ -61,6 +67,13 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
       })
       if (!job) return apiError(404, 'not_found', 'That job is not on your list.')
 
+      // Refused from the header, before the body is buffered into memory.
+      try {
+        assertContentLength(request, MAX_BODY)
+      } catch {
+        return apiError(413, 'invalid_request', 'That file is too large. Keep it under 60 MB.')
+      }
+
       const form = await request.formData()
       const file = form.get('file')
       if (!(file instanceof File)) {
@@ -78,7 +91,15 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
         return apiError(400, 'invalid_request', 'That file is too large. Keep it under 60 MB.')
       }
 
-      const category = file.type.startsWith('video/') ? 'video' : 'image'
+      const bytes = new Uint8Array(await file.arrayBuffer())
+
+      // The declared type is the client's word. A video is kept, served and
+      // played as one only when its bytes are the container it claims.
+      if (isDeclaredVideo(file.type) && !sniffDeclaredVideo(file.type, bytes)) {
+        return apiError(400, 'invalid_request', 'That file is not a video we can read.')
+      }
+
+      const category = isDeclaredVideo(file.type) ? 'video' : 'image'
       const description = (form.get('description') as string | null)?.slice(0, 500) || undefined
 
       const limitKey = CATEGORY_LIMIT[category]
@@ -105,7 +126,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
       await mkdir(dir, { recursive: true })
       const target = path.join(dir, filename)
 
-      await writeFile(target, new Uint8Array(await file.arrayBuffer()))
+      await writeFile(target, bytes)
       const written = await stat(target)
 
       const attachment = await db.serviceAttachment.create({

+ 25 - 1
src/app/api/v1/tech/jobs/[id]/status-report/route.ts

@@ -7,6 +7,8 @@ import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
 import { sendStatusReport } from '@/features/status-reports/Actions/sendStatusReport'
 import { uploadsRoot } from '@/lib/upload-root'
+import { assertContentLength } from '@/lib/backup/zip-guard'
+import { sniffDeclaredVideo } from '@/lib/video-sniff'
 
 /**
  * A short update for the customer, recorded standing at the car.
@@ -39,6 +41,10 @@ const ALLOWED_VIDEO = new Map<string, string>([
 /** Roughly two minutes of phone video. Longer than that is not a status update. */
 const MAX_BYTES = 120 * 1024 * 1024
 
+// The multipart framing and the text fields add a little to the body. The
+// margin keeps a video just under the cap from being refused for it.
+const MAX_BODY = MAX_BYTES + 1024 * 1024
+
 /** Matches the web's default: a link that outlives the repair by a fortnight. */
 const DEFAULT_TTL_MS = 14 * 24 * 60 * 60 * 1000
 
@@ -58,6 +64,17 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
       })
       if (!job) return apiError(404, 'not_found', 'That job is not on your list.')
 
+      // Refused from the header, before the body is buffered into memory.
+      try {
+        assertContentLength(request, MAX_BODY)
+      } catch {
+        return apiError(
+          413,
+          'invalid_request',
+          'That video is too long. Keep it under two minutes.'
+        )
+      }
+
       const form = await request.formData()
       const title = (form.get('title') as string | null)?.slice(0, 200) || undefined
       const message = (form.get('message') as string | null)?.slice(0, 4000) || undefined
@@ -80,12 +97,19 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
           )
         }
 
+        // The declared type is the client's word. The video is kept and
+        // sent to the customer only when its bytes are the container it claims.
+        const bytes = new Uint8Array(await video.arrayBuffer())
+        if (!sniffDeclaredVideo(video.type, bytes)) {
+          return apiError(400, 'invalid_request', 'That file is not a video we can read.')
+        }
+
         // Generated name, never the client's. See the attachments route.
         const filename = `${crypto.randomUUID()}.${ext}`
         const dir = path.join(uploadsRoot(), ctx.organizationId, 'services')
         await mkdir(dir, { recursive: true })
         const target = path.join(dir, filename)
-        await writeFile(target, new Uint8Array(await video.arrayBuffer()))
+        await writeFile(target, bytes)
         await stat(target)
 
         videoUrl = `/api/protected/files/${ctx.organizationId}/services/${filename}`

+ 6 - 1
src/app/api/v1/tech/org/[orgId]/auth/verify/route.ts

@@ -107,8 +107,13 @@ export async function POST(request: Request, { params }: { params: Promise<{ org
   })
   if (burned.count === 0) return bad('invalid_code')
 
+  // Bound to this workshop, in the same insert that makes it. The code proves
+  // the person holds a phone or inbox this workshop has on file, which is
+  // this workshop's word and no one else's (lib/session-scope.ts).
   const ctx = await auth.$context
-  const session = await ctx.internalAdapter.createSession(technician.userId, false)
+  const session = await ctx.internalAdapter.createSession(technician.userId, false, {
+    organizationId: orgId,
+  })
 
   logAudit(
     { userId: technician.userId, organizationId: orgId },

+ 7 - 1
src/app/api/v1/tech/setup/redeem/route.ts

@@ -73,8 +73,14 @@ export async function POST(request: Request) {
   // Two phones scanning the same screen at once. Exactly one of them wins.
   if (burned.count === 0) return bad('code_used')
 
+  // Bound to the workshop that issued the code, in the same insert that makes
+  // it. The desk can vouch for this person here and nowhere else, so the
+  // session cannot be pointed at any other workshop they belong to, or used
+  // for anything that belongs to their account (lib/session-scope.ts).
   const ctx = await auth.$context
-  const session = await ctx.internalAdapter.createSession(record.userId, false)
+  const session = await ctx.internalAdapter.createSession(record.userId, false, {
+    organizationId: record.organizationId,
+  })
 
   logAudit(
     { userId: record.userId, organizationId: record.organizationId },

+ 20 - 11
src/app/api/v1/tech/ws/route.ts

@@ -3,6 +3,7 @@ import type WebSocket from 'ws'
 import { auth } from '@/lib/auth'
 import { db } from '@/lib/db'
 import { notificationBus } from '@/lib/notification-bus'
+import { sessionOrganizationScope } from '@/lib/session-scope'
 
 /**
  * Live updates for the technician app.
@@ -107,17 +108,25 @@ export function UPGRADE(ws: WebSocket, _server: unknown, request: IncomingMessag
         .find((p) => p.startsWith('org.'))
         ?.slice('org.'.length)
 
-      const membership =
-        (wantedOrg
-          ? await db.organizationMember.findFirst({
-              where: { userId: session.user.id, organizationId: wantedOrg },
-              select: { organizationId: true },
-            })
-          : null) ??
-        (await db.organizationMember.findFirst({
-          where: { userId: session.user.id },
-          select: { organizationId: true },
-        }))
+      //
+      // A session bound to one workshop (lib/session-scope.ts) does not get to
+      // choose: it is that workshop or nothing.
+      const scope = sessionOrganizationScope(session)
+      const membership = scope
+        ? await db.organizationMember.findFirst({
+            where: { userId: session.user.id, organizationId: scope },
+            select: { organizationId: true },
+          })
+        : ((wantedOrg
+            ? await db.organizationMember.findFirst({
+                where: { userId: session.user.id, organizationId: wantedOrg },
+                select: { organizationId: true },
+              })
+            : null) ??
+          (await db.organizationMember.findFirst({
+            where: { userId: session.user.id },
+            select: { organizationId: true },
+          })))
       if (!membership) {
         ws.close(4001, 'No organization')
         return

+ 59 - 35
src/features/settings/Actions/accountActions.ts

@@ -6,45 +6,22 @@ import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { z } from 'zod'
 import crypto from 'crypto'
 import { demoGuard } from '@/lib/demo'
+import { emailVerificationRequired } from '@/lib/email-verification-policy'
 
 const updateEmailSchema = z.object({
   email: z.string().email('Invalid email address'),
 })
 
-export async function updateEmail(data: { email: string }) {
-  return withAuth(
-    async ({ userId }) => {
-      demoGuard()
-      const parsed = updateEmailSchema.parse(data)
-
-      const existing = await db.user.findFirst({
-        where: { email: parsed.email, NOT: { id: userId } },
-      })
-
-      if (existing) {
-        throw new Error('Email is already in use')
-      }
-
-      await db.user.update({
-        where: { id: userId },
-        data: { email: parsed.email, emailVerified: false },
-      })
-
-      return { email: parsed.email }
-    },
-    {
-      requiredPermissions: [
-        { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
-      ],
-    }
-  )
-}
-
 /**
- * Request email change with verification.
- * Instead of changing the email immediately, sends a confirmation link
- * to the NEW email address. The user's current email stays unchanged
- * until they click the confirmation link.
+ * Changes the account's email. The only action that does.
+ *
+ * Where the install requires verification, this sends a confirmation link
+ * to the NEW address and nothing changes until it is opened. Where it does
+ * not, the address changes at once. There used to be a second action for
+ * the second case, and the page chose between them: the choice lived in the
+ * page, so the instant action answered anybody on any install, and an
+ * address nobody had proved could be claimed ahead of the person it belongs
+ * to and then invited into their workshop. Now the server reads the setting.
  *
  * Security:
  * - Random opaque token in URL (no user data leaked)
@@ -73,6 +50,33 @@ export async function requestEmailChange(data: { email: string }) {
 
       if (!user) throw new Error('User not found')
 
+      const { sendAccountMail } = await import('@/lib/account-mail')
+
+      // An install that does not require verification changes the address
+      // at once, as the admin chose; there may be no mail server to send a
+      // link through. The current address is still told, when mail works.
+      if (!(await emailVerificationRequired())) {
+        await db.user.update({
+          where: { id: userId },
+          data: { email: parsed.email, emailVerified: false },
+        })
+        if (user.email && user.email !== parsed.email) {
+          await sendAccountMail({
+            to: user.email,
+            subject: 'Your Torqvoice email was changed',
+            name: user.name,
+            paragraphs: [
+              'Your Torqvoice account has moved to a different email address. This address no longer signs in to it.',
+            ],
+            notes: [
+              'If this was you, there is nothing to do.',
+              'If it was not, contact your workshop owner or support straight away.',
+            ],
+          }).catch(() => undefined)
+        }
+        return { sent: false, email: parsed.email }
+      }
+
       // Generate a cryptographically random token
       const token = crypto.randomBytes(32).toString('hex')
 
@@ -98,7 +102,6 @@ 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 { sendAccountMail } = await import('@/lib/account-mail')
       await sendAccountMail({
         to: parsed.email,
         subject: 'Confirm your new Torqvoice email',
@@ -113,9 +116,30 @@ export async function requestEmailChange(data: { email: string }) {
         ],
       })
 
-      return { sent: true }
+      // The current address hears about it too. Somebody at an unattended
+      // screen can ask for the move; the person it belongs to is the one who
+      // should find out, before the link is clicked rather than after.
+      if (user.email && user.email !== parsed.email) {
+        await sendAccountMail({
+          to: user.email,
+          subject: 'A change to your Torqvoice email was requested',
+          name: user.name,
+          paragraphs: [
+            'Somebody signed in to your Torqvoice account asked to move it to a different email address. Nothing changes until the link sent to that address is opened.',
+          ],
+          notes: [
+            'If this was you, there is nothing to do here.',
+            'If it was not, change your password now, and sign out of the devices you do not recognise from Settings.',
+          ],
+        }).catch(() => undefined)
+      }
+
+      return { sent: true, email: parsed.email }
     },
     {
+      // The email is the account's, and whoever holds it can reset the
+      // password: never from a session a workshop minted for a phone.
+      accountLevel: true,
       requiredPermissions: [
         { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
       ],

+ 33 - 21
src/features/settings/Actions/deleteAccount.ts

@@ -4,31 +4,43 @@ import { db } from '@/lib/db'
 import { withAuth } from '@/lib/with-auth'
 import { deleteUserOrganizations } from '@/lib/delete-user-data'
 import { demoGuard } from '@/lib/demo'
+import { verifyReauth } from '@/lib/reauth.server'
 
-export async function deleteAccount() {
-  return withAuth(async ({ userId }) => {
-    demoGuard()
-    // Get user email to clean up invitations
-    const user = await db.user.findUnique({
-      where: { id: userId },
-      select: { email: true },
-    })
+/**
+ * Deletes the caller's account, and every workshop they are the only member
+ * of. `input` carries the password, or the two-factor code, that
+ * verifyReauth asks for: a session on its own is not enough to destroy this
+ * much (see lib/reauth.server.ts).
+ */
+export async function deleteAccount(input: { password?: string; totpCode?: string } = {}) {
+  return withAuth(
+    async ({ userId }) => {
+      demoGuard()
+      await verifyReauth(userId, input, 'account')
 
-    // Handle all org cleanup (delete empty orgs, reassign data in shared orgs)
-    await deleteUserOrganizations(userId)
+      // Get user email to clean up invitations
+      const user = await db.user.findUnique({
+        where: { id: userId },
+        select: { email: true },
+      })
 
-    // Delete the user — cascades sessions, accounts, 2FA
-    await db.user.delete({
-      where: { id: userId },
-    })
+      // Handle all org cleanup (delete empty orgs, reassign data in shared orgs)
+      await deleteUserOrganizations(userId)
 
-    // Clean up invitations for this email
-    if (user?.email) {
-      await db.teamInvitation.deleteMany({
-        where: { email: user.email },
+      // Delete the user, which cascades sessions, accounts and 2FA
+      await db.user.delete({
+        where: { id: userId },
       })
-    }
 
-    return { deleted: true }
-  })
+      // Clean up invitations for this email
+      if (user?.email) {
+        await db.teamInvitation.deleteMany({
+          where: { email: user.email },
+        })
+      }
+
+      return { deleted: true }
+    },
+    { accountLevel: true }
+  )
 }

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

@@ -3,6 +3,7 @@
 import { revalidatePath } from 'next/cache'
 import { getCachedSession } from '@/lib/cached-session'
 import { db } from '@/lib/db'
+import { SCOPED_SESSION_MESSAGE, sessionOrganizationScope } from '@/lib/session-scope'
 import { classifyUserAgent, describeUserAgent, type DeviceKind } from '@/lib/known-devices'
 import { logAudit } from '@/lib/audit'
 import { demoGuard } from '@/lib/demo'
@@ -55,6 +56,11 @@ export async function signOutDevice(input: unknown) {
   demoGuard()
   const session = await getCachedSession()
   if (!session?.user?.id) return { success: false as const, error: 'Unauthorized' }
+  // The account's sessions are the account's business, not the workshop's
+  // that minted this one for a phone (lib/session-scope.ts).
+  if (sessionOrganizationScope(session)) {
+    return { success: false as const, error: SCOPED_SESSION_MESSAGE }
+  }
   const id = sessionIdSchema.parse(input)
 
   const { count } = await db.session.deleteMany({ where: { id, userId: session.user.id } })
@@ -73,6 +79,9 @@ export async function signOutOtherDevices() {
   demoGuard()
   const session = await getCachedSession()
   if (!session?.user?.id) return { success: false as const, error: 'Unauthorized' }
+  if (sessionOrganizationScope(session)) {
+    return { success: false as const, error: SCOPED_SESSION_MESSAGE }
+  }
 
   const { count } = await db.session.deleteMany({
     where: { userId: session.user.id, id: { not: session.session.id } },

+ 19 - 2
src/features/team/Actions/createAppSetupCode.ts

@@ -10,6 +10,7 @@ import {
   hashSetupCode,
   SETUP_CODE_TTL_MS,
 } from '../Lib/appSetupCode'
+import { canIssueAppSetupCode } from '../Lib/appSetupRules'
 
 /**
  * Issues the one-time code that puts a technician's phone onto this workshop.
@@ -29,7 +30,7 @@ const schema = z.object({
 
 export async function createAppSetupCode(input: unknown) {
   return withAuth(
-    async ({ organizationId, userId: issuerId }) => {
+    async ({ organizationId, userId: issuerId, role, isAdmin }) => {
       const { userId } = schema.parse(input)
 
       // Only for people already in this workshop, and only for people who can
@@ -37,10 +38,26 @@ export async function createAppSetupCode(input: unknown) {
       // session for any user id sent to it, which is the whole ballgame.
       const member = await db.organizationMember.findFirst({
         where: { userId, organizationId },
-        select: { user: { select: { id: true, name: true, email: true } } },
+        select: {
+          role: true,
+          customRole: { select: { isAdmin: true } },
+          user: { select: { id: true, name: true, email: true } },
+        },
       })
       if (!member?.user) throw new Error('That person is not a member of this workshop.')
 
+      // Whoever holds the code is signed in as this person, so it is only
+      // issued looking down the team, never across or up (appSetupRules.ts).
+      const decision = canIssueAppSetupCode(
+        { userId: issuerId, role, isAdmin },
+        {
+          userId,
+          role: member.role,
+          customRoleIsAdmin: member.customRole?.isAdmin === true,
+        }
+      )
+      if (!decision.ok) throw new Error(decision.reason)
+
       const technician = await db.technician.findFirst({
         where: { userId, organizationId, isActive: true },
         select: { id: true },

+ 3 - 0
src/features/team/Actions/createNewOrganization.ts

@@ -63,6 +63,9 @@ export async function createNewOrganization(input: unknown) {
       return org
     },
     {
+      // A new workshop belongs to the account, not to the workshop a phone's
+      // session was minted for.
+      accountLevel: true,
       audit: ({ result }) => ({
         action: 'organization.create',
         entity: 'Organization',

+ 71 - 6
src/features/team/Actions/createTechnicianAccount.ts

@@ -3,14 +3,18 @@
 import { randomUUID } from 'node:crypto'
 import { revalidatePath } from 'next/cache'
 import { z } from 'zod'
-import { db } from '@/lib/db'
+import { db, type TxClient } from '@/lib/db'
 import { notificationBus } from '@/lib/notification-bus'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { normalizeCountryCode } from '@/lib/portal-phone'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { normalizeOrgPhone } from '@/lib/sms'
 import { withAuth } from '@/lib/with-auth'
-import { ensureTechnicianRole, PLACEHOLDER_EMAIL_DOMAIN } from '../Lib/technicianRole'
+import {
+  ensureTechnicianRole,
+  isPlaceholderEmail,
+  PLACEHOLDER_EMAIL_DOMAIN,
+} from '../Lib/technicianRole'
 import { revokeTechnicianCredentials } from '../Lib/revokeTechnicianCredentials'
 
 /**
@@ -124,6 +128,15 @@ export async function createTechnicianAccount(input: unknown) {
         // Takeover. The number moves to the new person and the old record keeps
         // everything it ever did, minus the ability to sign in. Nobody's work
         // is rewritten; somebody's way in is closed.
+        //
+        // Unless that way in is also how they reach another workshop. The
+        // number is on their account, not on this membership, and this desk
+        // does not get to cut somebody off from a workshop it is not part of.
+        if (await belongsElsewhere(existing.userId, organizationId)) {
+          throw new Error(
+            'That number belongs to an account that is also a member of another workshop. Ask them to change their number from their own account.'
+          )
+        }
         await db.user.update({ where: { id: existing.userId }, data: { phone: null } })
         await revokeTechnicianCredentials(organizationId, existing.userId)
         // Falls through to a fresh account below, deliberately. This is a
@@ -236,12 +249,52 @@ async function reinstate(
   email: string | null
 ) {
   const technician = await db.$transaction(async (tx) => {
-    // A married name, a corrected spelling. Whatever the desk typed now is
-    // more current than whatever was typed before.
-    await tx.user.update({
+    const current = await tx.user.findUnique({
       where: { id: userId },
-      data: { name, ...(email ? { email } : {}) },
+      select: { email: true },
     })
+    if (!current) throw new Error('That account no longer exists.')
+
+    // The account is theirs, and the membership is ours. Whatever the desk
+    // types reaches the account only while this is the one workshop it
+    // belongs to; once it also opens the door to somebody else's workshop,
+    // changing its name or its address from here is not a correction, it is
+    // a way in. Rewriting the address and then asking for a password reset
+    // on it would have handed this desk their account everywhere.
+    const shared = await belongsElsewhere(userId, organizationId, tx)
+    const wantsEmail = !!email && email !== current.email
+    if (wantsEmail && shared) {
+      throw new Error(
+        'This person is also a member of another workshop, so their email is theirs to change. Leave it empty to bring them back as they are.'
+      )
+    }
+    // An address that was invented at the counter is nobody's, so it can be
+    // replaced. A real one was chosen by the person it reaches, and the desk
+    // is not that person.
+    if (wantsEmail && !isPlaceholderEmail(current.email)) {
+      throw new Error(
+        'This person already has an email on their account. They can change it from their own account settings.'
+      )
+    }
+    if (wantsEmail) {
+      const taken = await tx.user.findUnique({
+        where: { email: email as string },
+        select: { id: true },
+      })
+      if (taken && taken.id !== userId) {
+        throw new Error('Someone already has an account with that email. Invite them instead.')
+      }
+    }
+
+    if (!shared) {
+      // A married name, a corrected spelling. Whatever the desk typed now is
+      // more current than whatever was typed before. Nothing was sent to a
+      // new address, so nothing about it has been verified.
+      await tx.user.update({
+        where: { id: userId },
+        data: { name, ...(wantsEmail ? { email: email as string, emailVerified: false } : {}) },
+      })
+    }
 
     const roleId = await ensureTechnicianRole(tx, organizationId)
     const membership = await tx.organizationMember.findFirst({
@@ -276,6 +329,18 @@ async function reinstate(
   return { conflict: null, technicianId: technician.id, userId, name, reinstated: true }
 }
 
+/**
+ * Whether this account also opens some other workshop. A technician's number
+ * and address live on the account, so anything done to them here is done
+ * there as well.
+ */
+async function belongsElsewhere(userId: string, organizationId: string, tx: TxClient = db) {
+  const elsewhere = await tx.organizationMember.count({
+    where: { userId, organizationId: { not: organizationId } },
+  })
+  return elsewhere > 0
+}
+
 /** Stores the workshop's country code the first time somebody supplies one. */
 async function rememberDialCode(organizationId: string, userId: string, dial: string) {
   const existing = await db.appSetting.findUnique({

+ 8 - 3
src/features/team/Actions/deleteWorkshop.ts

@@ -6,13 +6,17 @@ import { db } from '@/lib/db'
 import { withAuth } from '@/lib/with-auth'
 import { demoGuard } from '@/lib/demo'
 import { deleteOrganizationWithData } from '@/lib/delete-user-data'
+import { reauthInputSchema, verifyReauth } from '@/lib/reauth.server'
 
-const deleteWorkshopSchema = z.object({ confirmName: z.string() })
+const deleteWorkshopSchema = reauthInputSchema.extend({ confirmName: z.string() })
 
 /**
  * Owner-only self-service deletion of the active workshop and all of its
  * data. The typed name is re-checked server-side so the destructive call
- * can never be reached by a forged client request alone.
+ * can never be reached by a forged client request alone, and the owner proves
+ * who they are again (password, two-factor code, or a fresh sign-in; see
+ * lib/reauth.server.ts), since an open session alone could otherwise delete a
+ * whole workshop.
  *
  * No audit entry: audit rows carry the organizationId, and inserting one
  * for an organization that no longer exists violates the foreign key.
@@ -37,10 +41,11 @@ export async function deleteWorkshop(input: unknown) {
     })
     if (!org) throw new Error('Workshop not found')
 
-    const { confirmName } = deleteWorkshopSchema.parse(input)
+    const { confirmName, password, totpCode } = deleteWorkshopSchema.parse(input)
     if (confirmName !== org.name) {
       throw new Error('The confirmation text does not match the workshop name')
     }
+    await verifyReauth(userId, { password, totpCode }, 'workshop')
 
     await deleteOrganizationWithData(organizationId, userId)
 

+ 8 - 2
src/features/team/Actions/removeTechnicianAccess.ts

@@ -2,6 +2,7 @@
 
 import { revalidatePath } from 'next/cache'
 import { z } from 'zod'
+import { getCachedSession } from '@/lib/cached-session'
 import { db } from '@/lib/db'
 import { notificationBus } from '@/lib/notification-bus'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
@@ -39,7 +40,7 @@ const schema = z.object({
 
 export async function removeTechnicianAccess(input: unknown) {
   return withAuth(
-    async ({ organizationId }) => {
+    async ({ organizationId, userId: callerId }) => {
       const { userId } = schema.parse(input)
 
       const technician = await db.technician.findFirst({
@@ -53,7 +54,12 @@ export async function removeTechnicianAccess(input: unknown) {
         data: { isActive: false },
       })
 
-      await revokeTechnicianCredentials(organizationId, technician.userId)
+      // Pressing this on your own row signs your phone out, not the browser
+      // you are standing in.
+      const current = technician.userId === callerId ? await getCachedSession() : null
+      await revokeTechnicianCredentials(organizationId, technician.userId, {
+        keepSessionToken: current?.session?.token ?? null,
+      })
 
       notificationBus.emit('workboard', {
         type: 'technician_updated',

+ 8 - 0
src/features/team/Actions/switchOrganization.ts

@@ -2,6 +2,7 @@
 
 import { cookies, headers } from 'next/headers'
 import { auth } from '@/lib/auth'
+import { SCOPED_SESSION_MESSAGE, sessionOrganizationScope } from '@/lib/session-scope'
 import { db } from '@/lib/db'
 import { revalidatePath } from 'next/cache'
 
@@ -14,6 +15,13 @@ export async function switchOrganization(organizationId: string) {
     return { success: false, error: 'Unauthorized' }
   }
 
+  // A session bound to one workshop stays in it (lib/session-scope.ts). The
+  // cookie would be ignored anyway; refusing says so instead of appearing to
+  // switch and then showing the same workshop.
+  if (sessionOrganizationScope(session)) {
+    return { success: false, error: SCOPED_SESSION_MESSAGE }
+  }
+
   const membership = await db.organizationMember.findFirst({
     where: { userId: session.user.id, organizationId },
     select: { organizationId: true },

+ 13 - 2
src/features/team/Actions/teamActions.ts

@@ -12,6 +12,7 @@ import { revalidatePath } from 'next/cache'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { setTechnicianStanding } from '../Lib/technicianStanding'
 import { revokeTechnicianCredentials } from '../Lib/revokeTechnicianCredentials'
+import { emailVerificationRequired } from '@/lib/email-verification-policy'
 import {
   getFeatures,
   isCloudMode,
@@ -126,6 +127,9 @@ export async function createOrganization(input: unknown) {
       return org
     },
     {
+      // A new workshop belongs to the account, not to the workshop a phone's
+      // session was minted for.
+      accountLevel: true,
       requiredPermissions: [
         { action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS },
       ],
@@ -175,10 +179,17 @@ export async function inviteMember(input: unknown) {
       // under pending, which is all the desk needs.
       const invitedUser = await db.user.findFirst({
         where: { email: data.email },
-        select: { id: true },
+        select: { id: true, emailVerified: true },
       })
 
-      if (!invitedUser) {
+      // An account joins on the spot only when its address has been proved,
+      // or on an install that does not ask for proof. Otherwise the address
+      // is a claim, and the person it reaches is the one the desk means;
+      // they get the invitation mail and accept it, and an account that
+      // merely typed the address gets nothing.
+      const seatsDirectly =
+        !!invitedUser && (invitedUser.emailVerified || !(await emailVerificationRequired()))
+      if (!seatsDirectly) {
         await createAndSendInvitation({
           organizationId,
           organizationName: membership.organization.name,

+ 47 - 0
src/features/team/Lib/appSetupRules.ts

@@ -0,0 +1,47 @@
+/**
+ * Who may set up the technician app for whom.
+ *
+ * A setup code signs its holder in as the person it was issued for, and the
+ * desk that issues it is the one holding the QR. So issuing one is, in effect,
+ * borrowing that person's standing in this workshop for as long as the session
+ * lasts. That is only safe looking down: an admin borrowing an admin's
+ * standing, or the owner's, is a way up the ladder that no other screen allows
+ * (see invitationRules.ts, where handing out admin is the owner's alone for
+ * the same reason).
+ *
+ * Standing is the owner, then admins (the built-in role or a custom role with
+ * the admin switch), then everybody else. The owner may set up anyone, and
+ * anyone may set up their own phone.
+ */
+
+import type { InviteDecision } from './invitationRules'
+
+export type SetupCaller = { userId: string; role: string; isAdmin: boolean }
+export type SetupTarget = { userId: string; role: string; customRoleIsAdmin: boolean }
+
+/** Super admins sit with the owner, as they do when handing out admin. */
+const OWNER_STANDING = new Set(['owner', 'super_admin'])
+
+function standing(role: string, isAdmin: boolean): number {
+  if (OWNER_STANDING.has(role)) return 2
+  if (role === 'admin' || isAdmin) return 1
+  return 0
+}
+
+export function canIssueAppSetupCode(caller: SetupCaller, target: SetupTarget): InviteDecision {
+  if (target.userId === caller.userId) return { ok: true }
+
+  const callerStanding = standing(caller.role, caller.isAdmin)
+  if (callerStanding === 2) return { ok: true }
+
+  const targetStanding = standing(target.role, target.customRoleIsAdmin)
+  if (targetStanding < callerStanding) return { ok: true }
+
+  if (targetStanding === 2) {
+    return { ok: false, reason: 'Only the owner can set up the app for the owner.' }
+  }
+  if (targetStanding === 1) {
+    return { ok: false, reason: 'Only the owner can set up the app for an admin.' }
+  }
+  return { ok: false, reason: 'Only an owner or admin can set up the app for someone else.' }
+}

+ 29 - 13
src/features/team/Lib/revokeTechnicianCredentials.ts

@@ -21,7 +21,15 @@ import { db } from '@/lib/db'
  */
 export async function revokeTechnicianCredentials(
   organizationId: string,
-  userId: string | null
+  userId: string | null,
+  options: {
+    /**
+     * The session doing the revoking, when it belongs to the same person.
+     * Somebody signing their own phone out from the team page is not asking
+     * to be signed out of the browser they are pressing the button in.
+     */
+    keepSessionToken?: string | null
+  } = {}
 ): Promise<void> {
   if (!userId) return
 
@@ -37,22 +45,30 @@ export async function revokeTechnicianCredentials(
     db.technicianSetupCode.deleteMany({ where: { organizationId, userId } }),
   ])
 
-  // Only where this person is a member of nowhere else. Somebody covering two
-  // branches of a chain should not be signed out of the other one because this
-  // branch let them go.
+  // Every session only where this person is a member of nowhere else.
+  // Somebody covering two branches of a chain should not be signed out of the
+  // other one because this branch let them go. The sessions this workshop
+  // minted for a phone (lib/session-scope.ts) are its own, though, and good
+  // for nothing anywhere else, so those end either way.
   const elsewhere = await db.organizationMember.count({
     where: { userId, organizationId: { not: organizationId } },
   })
 
-  if (elsewhere === 0) {
-    const ctx = await auth.$context
-    const sessions = await db.session.findMany({ where: { userId }, select: { token: true } })
-    // Through Better Auth rather than a raw delete, so its own caches let go
-    // of them too.
-    await Promise.all(
-      sessions.map((s) => ctx.internalAdapter.deleteSession(s.token).catch(() => undefined))
-    )
-  }
+  const ctx = await auth.$context
+  const sessions = await db.session.findMany({
+    where: elsewhere === 0 ? { userId } : { userId, organizationId },
+    select: { token: true, organizationId: true },
+  })
+  // Through Better Auth rather than a raw delete, so its own caches let go
+  // of them too. The one pressing the button is spared, unless it is itself
+  // a session this workshop minted for a phone, which is exactly what is
+  // being revoked.
+  const keep = options.keepSessionToken
+  await Promise.all(
+    sessions
+      .filter((s) => !(keep && s.token === keep && s.organizationId !== organizationId))
+      .map((s) => ctx.internalAdapter.deleteSession(s.token).catch(() => undefined))
+  )
 
   await db.pushDevice.updateMany({
     where: { userId, organizationId },

+ 13 - 2
src/features/vehicles/Schema/noteSchema.ts

@@ -1,13 +1,24 @@
 import { z } from 'zod'
+import { sanitizeHtml } from '@/lib/sanitize-html'
+
+/**
+ * A note is HTML from the rich text editor, and the vehicle page renders it as
+ * HTML. The editor is only a convenience on the client; the action takes any
+ * string, so the string is cleaned here, where every path through the action
+ * meets it, and not only where it happens to be typed.
+ */
+const noteContent = z.string().min(1, 'Content is required').transform(sanitizeHtml)
 
 export const createNoteSchema = z.object({
   vehicleId: z.string(),
   title: z.string().min(1, 'Title is required'),
-  content: z.string().min(1, 'Content is required'),
+  content: noteContent,
   isPinned: z.boolean().default(false),
 })
 
-export const updateNoteSchema = createNoteSchema.partial().extend({
+// A note stays on the vehicle it was written for; moving one to a vehicle the
+// caller never proved is theirs is not an edit.
+export const updateNoteSchema = createNoteSchema.omit({ vehicleId: true }).partial().extend({
   id: z.string(),
 })
 

+ 38 - 2
src/lib/auth.ts

@@ -1,6 +1,7 @@
 import { passkey } from '@better-auth/passkey'
 import { betterAuth } from 'better-auth'
 import { prismaAdapter } from 'better-auth/adapters/prisma'
+import { APIError, createAuthMiddleware } from 'better-auth/api'
 import { nextCookies } from 'better-auth/next-js'
 import { bearer } from 'better-auth/plugins/bearer'
 import { twoFactor } from 'better-auth/plugins/two-factor'
@@ -10,6 +11,7 @@ import { noteDevice, sendNewDeviceMail } from '@/lib/known-devices'
 import { sendAccountMail } from '@/lib/account-mail'
 import { isDemoMode } from './demo'
 import { googleSignInConfig } from './auth-providers'
+import { scopedSessionMayCall, sessionTokensInHeaders } from './session-scope'
 
 const baseURL = process.env.NEXT_PUBLIC_APP_URL
 const google = googleSignInConfig()
@@ -188,6 +190,35 @@ export const auth = betterAuth({
     cookieCache: {
       enabled: false,
     },
+    // The workshop a passwordless phone sign-in is bound to (see
+    // lib/session-scope.ts). Declared so getSession hands it back with the
+    // session, and so the setup and one-time code routes can write it in the
+    // same insert that creates the session. Never accepted from a caller.
+    additionalFields: {
+      organizationId: { type: 'string', required: false, input: false },
+    },
+  },
+  hooks: {
+    // A session bound to one workshop gets nothing from the auth handler but
+    // its own session and a sign-out. The rest of it is the account's: the
+    // other sessions and their tokens, passkeys, the password, the name.
+    before: createAuthMiddleware(async (ctx) => {
+      if (scopedSessionMayCall(ctx.path)) return
+      const tokens = [
+        ...sessionTokensInHeaders(ctx.request?.headers, ctx.context.authCookies.sessionToken.name),
+        ...sessionTokensInHeaders(ctx.headers, ctx.context.authCookies.sessionToken.name),
+      ]
+      if (tokens.length === 0) return
+      const scoped = await db.session.findFirst({
+        where: { token: { in: tokens }, organizationId: { not: null } },
+        select: { id: true },
+      })
+      if (scoped) {
+        throw new APIError('FORBIDDEN', {
+          message: 'This sign-in only works in the technician app.',
+        })
+      }
+    }),
   },
   advanced: {
     useSecureCookies: isProduction,
@@ -248,9 +279,14 @@ export const auth = betterAuth({
             }
           }
 
-          // Audit: log successful login
+          // Audit: log successful login. A session bound to a workshop is
+          // logged there, not in whichever workshop the account joined first.
+          const boundTo = (session as Record<string, unknown>).organizationId
           const membership = await db.organizationMember.findFirst({
-            where: { userId: session.userId },
+            where: {
+              userId: session.userId,
+              ...(typeof boundTo === 'string' && boundTo ? { organizationId: boundTo } : {}),
+            },
             select: { organizationId: true },
           })
           logAudit(

+ 2 - 0
src/lib/backup/manifest.ts

@@ -375,6 +375,8 @@ export const EXCLUDED_MODELS: Readonly<Record<string, string>> = {
   OrganizationMember: 'Membership of user accounts; people are restored by inviting them.',
   PushDevice:
     'Push token bound to one phone and one user account, and a backup carries neither. The app registers a new one on next launch.',
+  Session:
+    'A signed-in device, bound to a workshop only when the workshop signed a phone in. A credential, so it never travels in a backup; the phone signs in again.',
   Subscription: 'Billing state owned by Stripe, not by us.',
   TechnicianLoginCode:
     'One-time code for signing a technician back in, dead five minutes after it is sent.',

+ 36 - 1
src/lib/cached-session.ts

@@ -2,12 +2,19 @@ import { cache } from 'react'
 import { cookies, headers } from 'next/headers'
 import { auth } from './auth'
 import { db } from './db'
+import { SCOPED_SESSION_MESSAGE, sessionOrganizationScope } from './session-scope'
 
 export const getCachedSession = cache(async () => {
   return auth.api.getSession({ headers: await headers() })
 })
 
 export const getCachedMembership = cache(async (userId: string) => {
+  // A session minted from a setup code or a one-time code is bound to the
+  // workshop that minted it, and neither the header nor the cookie below may
+  // name another (lib/session-scope.ts).
+  const scope = await getCachedSessionScope()
+  if (scope) return resolveMembership(userId, scope, { bound: true })
+
   const cookieStore = await cookies()
 
   // Which workshop the caller means.
@@ -51,7 +58,11 @@ const MEMBERSHIP_SELECT = {
  * this person really has, so nothing here can place anybody in a workshop
  * they are not a member of.
  */
-export async function resolveMembership(userId: string, preferredOrganizationId?: string) {
+export async function resolveMembership(
+  userId: string,
+  preferredOrganizationId?: string,
+  options: { bound?: boolean } = {}
+) {
   if (preferredOrganizationId) {
     const preferred = await db.organizationMember.findFirst({
       where: { userId, organizationId: preferredOrganizationId },
@@ -59,5 +70,29 @@ export async function resolveMembership(userId: string, preferredOrganizationId?
     })
     if (preferred) return preferred
   }
+  // A session bound to a workshop has no other to fall back to. If the person
+  // has left it, the session is good for nothing, and saying so is better
+  // than quietly placing them in a workshop that never vouched for it.
+  if (options.bound) return null
   return db.organizationMember.findFirst({ where: { userId }, select: MEMBERSHIP_SELECT })
 }
+
+/**
+ * The workshop the current request's session is bound to, or null for an
+ * ordinary sign-in.
+ */
+export async function getCachedSessionScope(): Promise<string | null> {
+  return sessionOrganizationScope(await getCachedSession())
+}
+
+/**
+ * Refuses a session bound to one workshop.
+ *
+ * For actions that belong to the account rather than to a workshop: changing
+ * the email, deleting the account, starting a new workshop. A workshop that
+ * signed a phone in without a password can vouch for the person inside that
+ * workshop and nowhere else, and these reach everywhere.
+ */
+export async function assertAccountSession(): Promise<void> {
+  if (await getCachedSessionScope()) throw new Error(SCOPED_SESSION_MESSAGE)
+}

+ 3 - 2
src/lib/delete-user-data.ts

@@ -4,7 +4,7 @@ import { billingRequest, isTorqvoiceComBillingConfigured } from '@/lib/torqvoice
 
 /**
  * Delete an organization completely: cancels its Stripe subscription, deletes
- * the org row (cascading all data), and removes its upload files from disk.
+ * the org row (cascading all data), and moves its upload files to the trash.
  * The single implementation behind admin deletion, account deletion and the
  * owner's "delete workshop" — org deletion has ordering constraints (see the
  * inspections note below), so new deletion paths must call this rather than
@@ -49,7 +49,8 @@ export async function deleteOrganizationWithData(organizationId: string, userId?
   ])
 
   // Every file the workshop uploaded lives in its own folder, so that folder
-  // is removed whole, under each upload root. Nothing is unlinked by the URLs
+  // goes to the trash whole, under each upload root, and is purged with the
+  // rest of the trash TRASH_DAYS later. Nothing is unlinked by the URLs
   // its rows held: a row restored from another workshop's backup can still
   // name that workshop, and its files are not this one's to delete.
   await removeOrganizationFiles(organizationId)

+ 22 - 0
src/lib/email-verification-policy.ts

@@ -0,0 +1,22 @@
+import { db } from './db'
+import { SYSTEM_SETTING_KEYS } from '@/features/admin/Schema/systemSettingsSchema'
+
+/**
+ * Whether this install treats an email address as proved only after a link
+ * sent to it was opened.
+ *
+ * Off by default on a self-hosted install, where there may be no mail server
+ * at all and the people on it are the workshop's own. On means an address
+ * is a claim until confirmed: changing it goes through a confirmation mail,
+ * and inviting an address that an unconfirmed account holds mails the
+ * address rather than seating the account. Read here, on the server, by
+ * every action the answer changes; a page reading it on its own was a
+ * promise the server did not keep.
+ */
+export async function emailVerificationRequired(): Promise<boolean> {
+  const setting = await db.systemSetting.findUnique({
+    where: { key: SYSTEM_SETTING_KEYS.EMAIL_VERIFICATION_REQUIRED },
+    select: { value: true },
+  })
+  return setting?.value === 'true'
+}

+ 68 - 19
src/lib/files/manager.ts

@@ -1,7 +1,18 @@
 import 'server-only'
 
 import type { Dirent } from 'node:fs'
-import { constants, copyFile, lstat, mkdir, readdir, rename, rm, unlink } from 'node:fs/promises'
+import {
+  constants,
+  copyFile,
+  cp,
+  lstat,
+  mkdir,
+  readdir,
+  rename,
+  rm,
+  rmdir,
+  unlink,
+} from 'node:fs/promises'
 import path from 'node:path'
 import { UPLOAD_CATEGORIES } from '@/lib/upload-url'
 import { uploadsRoot, uploadsRoots } from '@/lib/upload-root'
@@ -249,29 +260,67 @@ function organizationDir(root: string, organizationId: string): string | null {
 }
 
 /**
- * Everything a workshop uploaded, under every root, and its trash: only when
- * the workshop itself is deleted. Gone at once, not trashed: nothing of a
- * deleted workshop is kept.
+ * Moves a file or folder to `destination`, merging into a folder already
+ * there: each child is moved in turn, and a name already taken by something
+ * else gets a counter, as in trashFile, so nothing in the trash is ever
+ * overwritten. A trash on another disk is reached by copy and delete, since a
+ * rename cannot cross disks.
  */
-export async function removeOrganizationFiles(organizationId: string): Promise<void> {
+async function moveInto(source: string, destination: string): Promise<void> {
+  if (await taken(destination)) {
+    const [from, to] = await Promise.all([lstat(source), lstat(destination)])
+    if (from.isDirectory() && to.isDirectory()) {
+      for (const child of await readdir(source)) {
+        await moveInto(path.join(source, child), path.join(destination, child))
+      }
+      // Empty by now; rmdir rather than rm, so a child that somehow stayed
+      // behind stops the removal instead of being deleted with it.
+      await rmdir(source)
+      return
+    }
+    let free = destination
+    for (let n = 1; await taken(free); n++) free = `${destination}.${n}`
+    destination = free
+  }
+  await mkdir(path.dirname(destination), { recursive: true })
+  try {
+    await rename(source, destination)
+  } catch (err) {
+    if ((err as NodeJS.ErrnoException).code !== 'EXDEV') throw err
+    await cp(source, destination, {
+      recursive: true,
+      errorOnExist: true,
+      force: false,
+      verbatimSymlinks: true,
+    })
+    await rm(source, { recursive: true, force: true })
+  }
+}
+
+/**
+ * Everything a workshop uploaded, under every root: only when the workshop
+ * itself is deleted. The folder is moved whole into that root's trash for
+ * today, `<uploads>/.trash/<date>/<org>`, which is the same layout a single
+ * released file lands in, so purgeTrash expires it TRASH_DAYS later like any
+ * other. It used to be removed at once, and one call from a hijacked or
+ * unattended session took every photo and document of the workshop beyond
+ * reach of anything but the server's own backup.
+ *
+ * Earlier trash of the workshop is left where it is, for the same reason.
+ */
+export async function removeOrganizationFiles(
+  organizationId: string,
+  { now = Date.now() }: { now?: number } = {}
+): Promise<void> {
+  const day = new Date(now).toISOString().slice(0, 10)
   for (const root of uploadsRoots()) {
     const dir = organizationDir(root, organizationId)
     if (!dir) return
-    await rm(dir, { recursive: true, force: true }).catch((err) =>
-      console.error(`[files] could not remove the uploads of ${organizationId}:`, err)
-    )
-    let days: Dirent[] = []
     try {
-      days = await readdir(path.join(path.resolve(root), TRASH), { withFileTypes: true })
-    } catch {
-      continue
-    }
-    for (const day of days) {
-      if (!day.isDirectory() || !DAY_FOLDER.test(day.name)) continue
-      await rm(path.join(path.resolve(root), TRASH, day.name, organizationId), {
-        recursive: true,
-        force: true,
-      }).catch(() => undefined)
+      if (!(await taken(dir))) continue
+      await moveInto(dir, path.join(path.resolve(root), TRASH, day, organizationId))
+    } catch (err) {
+      console.error(`[files] could not move the uploads of ${organizationId} to the trash:`, err)
     }
   }
 }

+ 4 - 1
src/lib/get-auth-context.ts

@@ -1,5 +1,6 @@
 import { getCachedSession, getCachedMembership } from './cached-session'
 import { db } from './db'
+import { sessionOrganizationScope } from './session-scope'
 import type { AuthContext } from './with-auth'
 
 type AuthContextResult =
@@ -26,7 +27,9 @@ export async function getAuthContextDetailed(): Promise<AuthContextResult> {
     }),
   ])
 
-  const isSuperAdmin = user?.isSuperAdmin ?? false
+  // None of the account's platform rights on a session bound to one workshop
+  // (lib/session-scope.ts).
+  const isSuperAdmin = !sessionOrganizationScope(session) && (user?.isSuperAdmin ?? false)
 
   if (!membership?.organizationId) return { status: 'no-organization' }
 

+ 4 - 1
src/lib/get-layout-data.ts

@@ -1,5 +1,6 @@
 import { getCachedSession, getCachedMembership } from './cached-session'
 import { db } from './db'
+import { sessionOrganizationScope } from './session-scope'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { typeKeyEnabledIn } from '@/features/vehicles/Lib/typeKeySetting'
 
@@ -52,7 +53,9 @@ export async function getLayoutData(): Promise<AuthResult> {
   // User record deleted (e.g. admin removed the account) but session cookie still cached
   if (!user) return { status: 'unauthenticated' }
 
-  const isSuperAdmin = user.isSuperAdmin
+  // None of the account's platform rights on a session bound to one workshop
+  // (lib/session-scope.ts).
+  const isSuperAdmin = !sessionOrganizationScope(session) && user.isSuperAdmin
 
   if (!membership && !isSuperAdmin) return { status: 'no-organization' }
 

+ 137 - 0
src/lib/reauth.server.ts

@@ -0,0 +1,137 @@
+import 'server-only'
+import { headers } from 'next/headers'
+import { z } from 'zod'
+import { auth } from '@/lib/auth'
+import { getCachedSession } from '@/lib/cached-session'
+import { db } from '@/lib/db'
+import { REAUTH_ERRORS, REAUTH_FRESH_SESSION_MINUTES, type ReauthRequirement } from '@/lib/reauth'
+
+/**
+ * Proof that the person at the keyboard is the account holder, asked for
+ * before the few actions that cannot be undone (deleting the account, deleting
+ * a workshop). A session alone is not that proof: it can be a laptop left open
+ * at the counter, or a cookie somebody else got hold of, and before this one
+ * call from either took the owner, the workshop and every file with it.
+ */
+
+export const reauthInputSchema = z.object({
+  password: z.string().max(1024).optional(),
+  totpCode: z.string().max(32).optional(),
+})
+
+export type ReauthInput = z.infer<typeof reauthInputSchema>
+
+/** The account's password hash, or null when it has none (Google or passkey only). */
+async function passwordHash(userId: string): Promise<string | null> {
+  const account = await db.account.findFirst({
+    where: { userId, providerId: 'credential' },
+    select: { password: true },
+  })
+  return account?.password || null
+}
+
+/** What this account is asked for; the dialogs read it to show the right field. */
+export async function reauthRequirement(userId: string): Promise<ReauthRequirement> {
+  if (await passwordHash(userId)) return 'password'
+  const user = await db.user.findUnique({
+    where: { id: userId },
+    select: { twoFactorEnabled: true },
+  })
+  return user?.twoFactorEnabled ? 'totp' : 'none'
+}
+
+/**
+ * Wrong answers per account before it has to wait. Without a limit a hijacked
+ * session could guess the password, or all million TOTP codes, through these
+ * actions, which do not pass the sign-in route's rate limit. In memory and on
+ * globalThis like the other per-process state: one server, and a restart
+ * giving a guesser a fresh budget of five is not worth a table.
+ */
+const FAILURE_LIMIT = 5
+const FAILURE_WINDOW_MS = 15 * 60_000
+
+const globalForReauth = globalThis as unknown as {
+  reauthFailures?: Map<string, { count: number; resetAt: number }>
+}
+const failures = (globalForReauth.reauthFailures ??= new Map())
+
+function assertNotLocked(userId: string, now: number) {
+  const entry = failures.get(userId)
+  if (entry && entry.resetAt > now && entry.count >= FAILURE_LIMIT) {
+    throw new Error(REAUTH_ERRORS.tooManyAttempts)
+  }
+}
+
+function fail(userId: string, now: number, message: string): never {
+  const entry = failures.get(userId)
+  if (!entry || entry.resetAt <= now) {
+    failures.set(userId, { count: 1, resetAt: now + FAILURE_WINDOW_MS })
+  } else {
+    entry.count += 1
+  }
+  throw new Error(message)
+}
+
+/** Forgets the failures counted against an account; for tests. */
+export function resetReauthFailures() {
+  failures.clear()
+}
+
+/**
+ * Throws unless the caller proved who they are just now:
+ *
+ * - an account with a password must give it;
+ * - one without a password but with two-factor on must give a current code
+ *   from the authenticator app;
+ * - one with neither (Google or passkey only) has nothing to type, so the
+ *   session itself must have been created in the last
+ *   REAUTH_FRESH_SESSION_MINUTES: signing in again is the proof.
+ *
+ * `purpose` only picks the sentence for a stale session.
+ */
+export async function verifyReauth(
+  userId: string,
+  input: unknown,
+  purpose: 'account' | 'workshop',
+  now: number = Date.now()
+): Promise<void> {
+  const { password, totpCode } = reauthInputSchema.parse(input ?? {})
+  assertNotLocked(userId, now)
+
+  const hash = await passwordHash(userId)
+  if (hash) {
+    if (!password) throw new Error(REAUTH_ERRORS.wrongPassword)
+    const ctx = await auth.$context
+    const valid = await ctx.password.verify({ hash, password }).catch(() => false)
+    if (!valid) fail(userId, now, REAUTH_ERRORS.wrongPassword)
+    failures.delete(userId)
+    return
+  }
+
+  const user = await db.user.findUnique({
+    where: { id: userId },
+    select: { twoFactorEnabled: true },
+  })
+  if (user?.twoFactorEnabled) {
+    const code = totpCode?.replace(/\s+/g, '')
+    if (!code) throw new Error(REAUTH_ERRORS.wrongCode)
+    // Better Auth's own check, against the caller's session: it decrypts the
+    // stored secret and allows the usual one-step clock drift.
+    const ok = await auth.api
+      .verifyTOTP({ body: { code }, headers: await headers() })
+      .then(() => true)
+      .catch(() => false)
+    if (!ok) fail(userId, now, REAUTH_ERRORS.wrongCode)
+    failures.delete(userId)
+    return
+  }
+
+  const session = await getCachedSession()
+  const createdAt = session?.user?.id === userId ? session.session.createdAt : null
+  const age = createdAt ? now - new Date(createdAt).getTime() : Number.POSITIVE_INFINITY
+  if (!(age <= REAUTH_FRESH_SESSION_MINUTES * 60_000)) {
+    throw new Error(
+      purpose === 'account' ? REAUTH_ERRORS.signInAgainAccount : REAUTH_ERRORS.signInAgainWorkshop
+    )
+  }
+}

+ 34 - 0
src/lib/reauth.ts

@@ -0,0 +1,34 @@
+/**
+ * What the delete-account and delete-workshop dialogs ask for before the
+ * server will go ahead, and the sentences it refuses with. Shared by the
+ * server check (reauth.server.ts) and the dialogs, which show the refusal in
+ * the person's own language by looking its key up here.
+ *
+ * - 'password': the account has a password, and must type it.
+ * - 'totp': no password, but two-factor is on: a code from the app.
+ * - 'none': neither (Google or passkey only), so the session itself must be
+ *   fresh, see REAUTH_FRESH_SESSION_MINUTES.
+ */
+export type ReauthRequirement = 'password' | 'totp' | 'none'
+
+/** How recent a sign-in must be when there is nothing else to ask for. */
+export const REAUTH_FRESH_SESSION_MINUTES = 10
+
+export const REAUTH_ERRORS = {
+  wrongPassword: 'Wrong password.',
+  wrongCode: 'Wrong code.',
+  tooManyAttempts: 'Too many attempts. Wait a few minutes and try again.',
+  signInAgainAccount: 'Sign in again before deleting your account.',
+  signInAgainWorkshop: 'Sign in again before deleting the workshop.',
+} as const
+
+export type ReauthError = keyof typeof REAUTH_ERRORS
+
+/** The `settings` message key for a refusal above, or null for any other error. */
+export function reauthErrorMessageKey(message: string | undefined): string | null {
+  const match = (Object.keys(REAUTH_ERRORS) as ReauthError[]).find(
+    (key) => REAUTH_ERRORS[key] === message
+  )
+  if (!match) return null
+  return `account.reauth${match.charAt(0).toUpperCase()}${match.slice(1)}`
+}

+ 93 - 0
src/lib/session-scope.ts

@@ -0,0 +1,93 @@
+/**
+ * Sessions bound to one workshop.
+ *
+ * A technician's phone can be signed in without a password: the desk issues
+ * an app setup code, or the workshop texts a one-time code. Either way it is
+ * the workshop vouching for the person, and a workshop can only vouch for
+ * itself. So those sessions carry the workshop that minted them
+ * (`sessions.organizationId`), and everything that decides which workshop a
+ * request acts in reads it before anything the caller says.
+ *
+ * Without it the token was the person's whole account. An admin who issued a
+ * setup code and scanned it themselves could send another workshop's id in
+ * `x-org-id` and act there with the technician's role, or change the
+ * technician's email and take the account.
+ *
+ * Ordinary sign-ins carry nothing here and behave exactly as before.
+ */
+
+/** What a bound session is told when it reaches for the account. */
+export const SCOPED_SESSION_MESSAGE =
+  'This sign-in only works in the technician app. Sign in with your password to change your account.'
+
+/** Paths under the auth handler a workshop-bound session may still call. */
+const SCOPED_SESSION_AUTH_PATHS = new Set(['/get-session', '/sign-out'])
+
+/**
+ * The workshop a session is bound to, or null for an ordinary sign-in.
+ *
+ * Takes what `auth.api.getSession` returns, which includes the column because
+ * it is declared under `session.additionalFields` in `./auth`.
+ */
+export function sessionOrganizationScope(
+  session: { session?: { organizationId?: string | null } | null } | null | undefined
+): string | null {
+  const scope = session?.session?.organizationId
+  return typeof scope === 'string' && scope ? scope : null
+}
+
+/**
+ * Whether a workshop-bound session may call this auth endpoint.
+ *
+ * Everything the auth handler offers beyond reading the session and signing
+ * out is about the account rather than a workshop: listing the account's
+ * other sessions (which hands back their tokens), adding a passkey, changing
+ * the password or the name. Any of those turns a session one workshop vouched
+ * for into a hold on the whole account, so none of them is open to it.
+ */
+export function scopedSessionMayCall(path: string | undefined): boolean {
+  return path !== undefined && SCOPED_SESSION_AUTH_PATHS.has(path)
+}
+
+/**
+ * Every session token a request to the auth handler carries, signed or not.
+ *
+ * Read from both the bearer header and the session cookie, because the bearer
+ * plugin turns one into the other only after hooks like ours have run. Used to
+ * refuse, never to grant, so the signature is not checked: a forged value can
+ * only ever get its sender refused.
+ */
+export function sessionTokensInHeaders(
+  headers: Headers | undefined | null,
+  cookieName: string
+): string[] {
+  if (!headers) return []
+  const tokens = new Set<string>()
+
+  const authorization = headers.get('authorization')
+  if (authorization?.slice(0, 7).toLowerCase() === 'bearer ') {
+    const token = tokenPart(authorization.slice(7).trim())
+    if (token) tokens.add(token)
+  }
+
+  for (const pair of (headers.get('cookie') ?? '').split(';')) {
+    const eq = pair.indexOf('=')
+    if (eq < 0) continue
+    if (pair.slice(0, eq).trim() !== cookieName) continue
+    const token = tokenPart(pair.slice(eq + 1).trim())
+    if (token) tokens.add(token)
+  }
+
+  return [...tokens]
+}
+
+/** A stored token is the part before the signature. */
+function tokenPart(value: string): string {
+  let decoded = value
+  try {
+    decoded = decodeURIComponent(value)
+  } catch {
+    // Not encoded, or badly: either way the raw value is what there is.
+  }
+  return decoded.split('.')[0] ?? ''
+}

+ 72 - 0
src/lib/video-sniff.ts

@@ -0,0 +1,72 @@
+/**
+ * What an uploaded video really is, read from its bytes.
+ *
+ * The MIME type on an upload is whatever the client says it is. A text file
+ * labelled video/mp4 that is really an HLS playlist or an ffmpeg concat
+ * script makes ffmpeg open the files it names, local ones included, and mux
+ * them into the output the uploader then downloads. So a video is only ever
+ * treated as one when its first bytes are the container it claims to be,
+ * and ffmpeg is then told which demuxer to use instead of guessing.
+ */
+
+/** The containers the upload routes accept, as found in the bytes. */
+export type VideoContainer = 'mp4' | 'webm'
+
+/** The video MIME types an upload may declare, and the container each must hold. */
+const DECLARED_CONTAINER: Record<string, VideoContainer> = {
+  'video/mp4': 'mp4',
+  'video/quicktime': 'mp4',
+  'video/webm': 'webm',
+}
+
+/** How many leading bytes the sniff needs. */
+export const VIDEO_SNIFF_BYTES = 12
+
+const EBML_MAGIC = [0x1a, 0x45, 0xdf, 0xa3]
+const FTYP = [0x66, 0x74, 0x79, 0x70] // "ftyp"
+
+function matchesAt(bytes: Uint8Array, offset: number, magic: number[]): boolean {
+  if (bytes.length < offset + magic.length) return false
+  return magic.every((byte, i) => bytes[offset + i] === byte)
+}
+
+/**
+ * The container the bytes start with, or null for anything else. MP4 and
+ * QuickTime both open with a box whose type, at byte 4, is "ftyp"; WebM and
+ * Matroska open with the EBML header.
+ */
+export function sniffVideoContainer(bytes: Uint8Array): VideoContainer | null {
+  if (bytes.length < VIDEO_SNIFF_BYTES) return null
+  if (matchesAt(bytes, 4, FTYP)) return 'mp4'
+  if (matchesAt(bytes, 0, EBML_MAGIC)) return 'webm'
+  return null
+}
+
+/** Whether the client declared the upload to be a video. */
+export function isDeclaredVideo(mimeType: string): boolean {
+  return mimeType.startsWith('video/')
+}
+
+/**
+ * The container of an upload declared as a video, or null when the bytes
+ * are not the container that type promises. A WebM labelled video/mp4 is
+ * refused too: the routes that keep the bytes as they came name and serve
+ * the file by its declared type.
+ */
+export function sniffDeclaredVideo(mimeType: string, bytes: Uint8Array): VideoContainer | null {
+  const expected = DECLARED_CONTAINER[mimeType]
+  if (!expected) return null
+  return sniffVideoContainer(bytes) === expected ? expected : null
+}
+
+/**
+ * The ffmpeg demuxer for a sniffed container. ffmpeg matches `-f` against
+ * the demuxer's own names, "mov,mp4,m4a,3gp,3g2,mj2" and "matroska,webm".
+ */
+export function ffmpegInputFormat(container: VideoContainer): 'mov' | 'matroska' {
+  return container === 'mp4' ? 'mov' : 'matroska'
+}
+
+/** The message for an upload that says it is a video but is not one. */
+export const NOT_A_VIDEO_MESSAGE =
+  'That file is not a video we can read. Upload an MP4, MOV or WebM file.'

+ 17 - 2
src/lib/with-api-auth.ts

@@ -6,6 +6,7 @@ import { db } from './db'
 import { hasAllPermissions, type PermissionInput } from './permissions'
 import { rateLimit } from './rate-limit'
 import { DocumentLockedError } from './document-lock'
+import { sessionOrganizationScope } from './session-scope'
 
 /**
  * Request wrapper for the token-authenticated API the technician app talks to.
@@ -109,17 +110,25 @@ export async function withApiAuth(
 
   const userId = session.user.id
 
+  // A session minted from a setup code or a one-time code is bound to the
+  // workshop that minted it (lib/session-scope.ts). It is that workshop and
+  // nothing else, and it carries none of the account's platform rights.
+  const scope = sessionOrganizationScope(session)
+
   const user = await db.user.findUnique({
     where: { id: userId },
     select: { isSuperAdmin: true },
   })
-  const isSuperAdmin = user?.isSuperAdmin ?? false
+  const isSuperAdmin = !scope && (user?.isSuperAdmin ?? false)
 
   // The app names which workshop it is talking to, because a user may belong
   // to several. The header only selects; it never grants. Membership is what
   // decides, and an id the user is not a member of falls through to their
   // default rather than erroring, so a stale header cannot lock the app out.
-  const requestedOrgId = request.headers.get('x-org-id')
+  //
+  // A bound session does not get to select. Its workshop is the only answer,
+  // and if the person has left it there is nothing to fall back to.
+  const requestedOrgId = scope ?? request.headers.get('x-org-id')
 
   const memberSelect = {
     organizationId: true,
@@ -137,6 +146,12 @@ export async function withApiAuth(
       })
     : null
 
+  // 401 rather than 403: this session can never work again, and the app
+  // treats a 401 as the cue to sign in afresh.
+  if (!membership && scope) {
+    return apiError(401, 'unauthorized', 'You are no longer a member of this workshop.')
+  }
+
   if (!membership) {
     membership = await db.organizationMember.findFirst({
       where: { userId },

+ 16 - 1
src/lib/with-auth.ts

@@ -9,6 +9,7 @@ import type { AuditEvent } from '@/lib/audit'
 import type { FeatureGatedError } from '@/lib/features'
 import { publicErrorMessage } from '@/lib/public-error-message'
 import { runAsActor } from '@/lib/realtime/actor.server'
+import { SCOPED_SESSION_MESSAGE, sessionOrganizationScope } from './session-scope'
 
 /** What the plan refused, and the number it stopped at when there is one. */
 export type GatedFeature = { feature: string; limit?: number }
@@ -39,6 +40,13 @@ type AuditBuilder<T> = (args: { ctx: AuthContext; result: T }) => AuditEvent | n
 
 type WithAuthOptions<T = unknown> = {
   requiredPermissions?: PermissionInput[]
+  /**
+   * The action changes the person's account rather than the workshop: their
+   * email, the account itself, a workshop of their own. Refused to a session
+   * a workshop minted without a password (lib/session-scope.ts), because that
+   * workshop can only vouch for the person inside itself.
+   */
+  accountLevel?: boolean
   // Optional audit config. If provided, runs after successful action.
   audit?: AuditEvent | AuditBuilder<T>
 }
@@ -66,12 +74,19 @@ export async function withAuth<T>(
       return { success: false, error: 'Unauthorized' }
     }
 
+    // A session bound to one workshop acts as a member of it and nothing
+    // more: no account changes, and none of the account's platform rights.
+    const scope = sessionOrganizationScope(session)
+    if (scope && options.accountLevel) {
+      return { success: false, error: SCOPED_SESSION_MESSAGE, forbidden: true }
+    }
+
     const user = await db.user.findUnique({
       where: { id: session.user.id },
       select: { isSuperAdmin: true },
     })
 
-    const isSuperAdmin = user?.isSuperAdmin ?? false
+    const isSuperAdmin = !scope && (user?.isSuperAdmin ?? false)
 
     const membership = await getCachedMembership(session.user.id)
 

+ 4 - 1
src/lib/with-super-admin.ts

@@ -1,5 +1,6 @@
 import { getCachedSession } from './cached-session'
 import { db } from './db'
+import { sessionOrganizationScope } from './session-scope'
 import type { ActionResult } from './with-auth'
 import { publicErrorMessage } from '@/lib/public-error-message'
 
@@ -22,7 +23,9 @@ export async function withSuperAdmin<T>(
       select: { isSuperAdmin: true },
     })
 
-    if (!user?.isSuperAdmin) {
+    // A session a workshop minted without a password (lib/session-scope.ts)
+    // carries none of the account's platform rights.
+    if (!user?.isSuperAdmin || sessionOrganizationScope(session)) {
       return { success: false, error: 'Forbidden' }
     }