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

Keep a shared link on screen until it is really revoked (#576)

* Keep a shared link on screen until it is really revoked

The quote, invoice and inspection share dialogs and the Shared Link card
cleared the link whatever the server answered, so a refused revoke or one
that never reached the server said the link was gone while the customer's
copy still opened the document. The link now stays and the screen says it
still works; a failed Generate says so too. The inspection share dialog's
hard-coded English is translated, and the email option's background read no
longer raises the lost-connection notice.

* Say a revoke could not be confirmed rather than that the link still works

A revoke that went through but whose answer was lost left a link that no
longer works, so the message no longer promises it does.
Bernt Christian Egeland 1 день назад
Родитель
Сommit
e5ee3868b5
35 измененных файлов с 554 добавлено и 64 удалено
  1. 4 0
      messages/de/common.json
  2. 13 1
      messages/de/inspections.json
  3. 4 0
      messages/en/common.json
  4. 13 1
      messages/en/inspections.json
  5. 4 0
      messages/es/common.json
  6. 13 1
      messages/es/inspections.json
  7. 4 0
      messages/fr/common.json
  8. 13 1
      messages/fr/inspections.json
  9. 4 0
      messages/it/common.json
  10. 13 1
      messages/it/inspections.json
  11. 4 0
      messages/lt/common.json
  12. 13 1
      messages/lt/inspections.json
  13. 4 0
      messages/nb/common.json
  14. 13 1
      messages/nb/inspections.json
  15. 4 0
      messages/nl/common.json
  16. 13 1
      messages/nl/inspections.json
  17. 4 0
      messages/pl/common.json
  18. 13 1
      messages/pl/inspections.json
  19. 4 0
      messages/pt-BR/common.json
  20. 13 1
      messages/pt-BR/inspections.json
  21. 4 0
      messages/pt-PT/common.json
  22. 13 1
      messages/pt-PT/inspections.json
  23. 4 0
      messages/ru/common.json
  24. 13 1
      messages/ru/inspections.json
  25. 4 0
      messages/tr/common.json
  26. 13 1
      messages/tr/inspections.json
  27. 241 0
      src/__tests__/features/share-link-revoke.test.tsx
  28. 11 7
      src/components/shared-link-card.tsx
  29. 9 5
      src/features/email/Components/AttachPdfOption.tsx
  30. 28 24
      src/features/inspections/Components/InspectionShareDialog.tsx
  31. 3 2
      src/features/quotes/Components/QuotePageClient.tsx
  32. 1 1
      src/features/quotes/Components/QuoteRightColumn.tsx
  33. 22 4
      src/features/quotes/Components/QuoteShareDialog.tsx
  34. 15 6
      src/features/vehicles/Components/service-detail/ShareDialog.tsx
  35. 3 2
      src/features/vehicles/Components/service-page/modern/ModernDetails.tsx

+ 4 - 0
messages/de/common.json

@@ -199,6 +199,10 @@
     "title": "Torqvoice ist nicht erreichbar",
     "description": "Ihre letzte Änderung wurde möglicherweise nicht gespeichert. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut."
   },
+  "shareLink": {
+    "generateFailed": "Link konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
+    "revokeFailed": "Der Widerruf des Links konnte nicht bestätigt werden. Er funktioniert möglicherweise noch für alle, die ihn haben."
+  },
   "searchableSelect": {
     "choose": "Auswählen...",
     "search": "Suchen...",

+ 13 - 1
messages/de/inspections.json

@@ -459,7 +459,19 @@
     "copyLink": "Link kopieren",
     "copied": "Link in die Zwischenablage kopiert",
     "generated": "Freigabelink erstellt",
-    "revoked": "Freigabelink zurückgezogen"
+    "revoked": "Freigabelink zurückgezogen",
+    "notifyTitle": "{name} benachrichtigen",
+    "email": "E-Mail",
+    "noEmail": "(keine E-Mail hinterlegt)",
+    "sms": "SMS",
+    "noPhone": "(keine Telefonnummer hinterlegt)",
+    "sendNotification": "Benachrichtigung senden",
+    "revokeLink": "Link widerrufen",
+    "generateLink": "Öffentlichen Link erstellen",
+    "emailSent": "E-Mail gesendet",
+    "failedEmail": "E-Mail konnte nicht gesendet werden",
+    "smsSent": "SMS gesendet",
+    "failedSms": "SMS konnte nicht gesendet werden"
   },
   "phone": {
     "pageTitle": "Fotos zur Prüfung",

+ 4 - 0
messages/en/common.json

@@ -199,6 +199,10 @@
     "title": "Couldn't reach Torqvoice",
     "description": "Your last change may not have been saved. Check your connection and try again."
   },
+  "shareLink": {
+    "generateFailed": "Couldn't create the link. Try again.",
+    "revokeFailed": "Couldn't confirm the link was revoked. It may still work for anyone who has it."
+  },
   "searchableSelect": {
     "choose": "Choose...",
     "search": "Search...",

+ 13 - 1
messages/en/inspections.json

@@ -459,7 +459,19 @@
     "copyLink": "Copy link",
     "copied": "Link copied to clipboard",
     "generated": "Share link generated",
-    "revoked": "Share link revoked"
+    "revoked": "Share link revoked",
+    "notifyTitle": "Notify {name}",
+    "email": "Email",
+    "noEmail": "(no email on file)",
+    "sms": "SMS",
+    "noPhone": "(no phone on file)",
+    "sendNotification": "Send Notification",
+    "revokeLink": "Revoke Link",
+    "generateLink": "Generate Public Link",
+    "emailSent": "Email sent",
+    "failedEmail": "Failed to send email",
+    "smsSent": "SMS sent",
+    "failedSms": "Failed to send SMS"
   },
   "phone": {
     "pageTitle": "Inspection photos",

+ 4 - 0
messages/es/common.json

@@ -199,6 +199,10 @@
     "title": "No se pudo conectar con Torqvoice",
     "description": "Puede que tu último cambio no se haya guardado. Revisa tu conexión e inténtalo de nuevo."
   },
+  "shareLink": {
+    "generateFailed": "No se pudo crear el enlace. Inténtalo de nuevo.",
+    "revokeFailed": "No se pudo confirmar que el enlace se revocó. Puede que siga funcionando para cualquiera que lo tenga."
+  },
   "searchableSelect": {
     "choose": "Elegir...",
     "search": "Buscar...",

+ 13 - 1
messages/es/inspections.json

@@ -459,7 +459,19 @@
     "copyLink": "Copiar enlace",
     "copied": "Enlace copiado al portapapeles",
     "generated": "Enlace de compartición creado",
-    "revoked": "Enlace de compartición revocado"
+    "revoked": "Enlace de compartición revocado",
+    "notifyTitle": "Notificar a {name}",
+    "email": "Correo electrónico",
+    "noEmail": "(sin correo registrado)",
+    "sms": "SMS",
+    "noPhone": "(sin teléfono registrado)",
+    "sendNotification": "Enviar notificación",
+    "revokeLink": "Revocar enlace",
+    "generateLink": "Generar enlace público",
+    "emailSent": "Correo enviado",
+    "failedEmail": "Error al enviar el correo",
+    "smsSent": "SMS enviado",
+    "failedSms": "Error al enviar el SMS"
   },
   "phone": {
     "pageTitle": "Fotos de la inspección",

+ 4 - 0
messages/fr/common.json

@@ -199,6 +199,10 @@
     "title": "Impossible de joindre Torqvoice",
     "description": "Votre dernière modification n'a peut-être pas été enregistrée. Vérifiez votre connexion et réessayez."
   },
+  "shareLink": {
+    "generateFailed": "Impossible de créer le lien. Veuillez réessayer.",
+    "revokeFailed": "Impossible de confirmer la révocation du lien. Il fonctionne peut-être encore pour toute personne qui l'a."
+  },
   "searchableSelect": {
     "choose": "Choisir...",
     "search": "Rechercher...",

+ 13 - 1
messages/fr/inspections.json

@@ -459,7 +459,19 @@
     "copyLink": "Copier le lien",
     "copied": "Lien copié dans le presse-papiers",
     "generated": "Lien de partage créé",
-    "revoked": "Lien de partage révoqué"
+    "revoked": "Lien de partage révoqué",
+    "notifyTitle": "Notifier {name}",
+    "email": "E-mail",
+    "noEmail": "(aucun e-mail enregistré)",
+    "sms": "SMS",
+    "noPhone": "(aucun téléphone enregistré)",
+    "sendNotification": "Envoyer la notification",
+    "revokeLink": "Révoquer le lien",
+    "generateLink": "Générer un lien public",
+    "emailSent": "E-mail envoyé",
+    "failedEmail": "Échec de l'envoi de l'e-mail",
+    "smsSent": "SMS envoyé",
+    "failedSms": "Échec de l'envoi du SMS"
   },
   "phone": {
     "pageTitle": "Photos de l'inspection",

+ 4 - 0
messages/it/common.json

@@ -199,6 +199,10 @@
     "title": "Impossibile raggiungere Torqvoice",
     "description": "L'ultima modifica potrebbe non essere stata salvata. Controlla la connessione e riprova."
   },
+  "shareLink": {
+    "generateFailed": "Impossibile creare il link. Riprova.",
+    "revokeFailed": "Impossibile confermare la revoca del link. Potrebbe funzionare ancora per chiunque lo abbia."
+  },
   "searchableSelect": {
     "choose": "Scegli...",
     "search": "Cerca...",

+ 13 - 1
messages/it/inspections.json

@@ -459,7 +459,19 @@
     "copyLink": "Copia link",
     "copied": "Link copiato negli appunti",
     "generated": "Link di condivisione creato",
-    "revoked": "Link di condivisione revocato"
+    "revoked": "Link di condivisione revocato",
+    "notifyTitle": "Notifica {name}",
+    "email": "E-mail",
+    "noEmail": "(nessuna e-mail registrata)",
+    "sms": "SMS",
+    "noPhone": "(nessun telefono registrato)",
+    "sendNotification": "Invia notifica",
+    "revokeLink": "Revoca link",
+    "generateLink": "Genera link pubblico",
+    "emailSent": "E-mail inviata",
+    "failedEmail": "Invio e-mail non riuscito",
+    "smsSent": "SMS inviato",
+    "failedSms": "Invio SMS non riuscito"
   },
   "phone": {
     "pageTitle": "Foto dell'ispezione",

+ 4 - 0
messages/lt/common.json

@@ -199,6 +199,10 @@
     "title": "Nepavyko pasiekti Torqvoice",
     "description": "Paskutinis pakeitimas galėjo būti neišsaugotas. Patikrinkite ryšį ir bandykite dar kartą."
   },
+  "shareLink": {
+    "generateFailed": "Nepavyko sukurti nuorodos. Bandykite dar kartą.",
+    "revokeFailed": "Nepavyko patvirtinti, kad nuoroda atšaukta. Ji vis dar gali veikti visiems, kurie ją turi."
+  },
   "searchableSelect": {
     "choose": "Pasirinkite...",
     "search": "Ieškoti...",

+ 13 - 1
messages/lt/inspections.json

@@ -459,7 +459,19 @@
     "copyLink": "Kopijuoti nuorodą",
     "copied": "Nuoroda nukopijuota į iškarpinę",
     "generated": "Bendrinimo nuoroda sukurta",
-    "revoked": "Bendrinimo nuoroda atšaukta"
+    "revoked": "Bendrinimo nuoroda atšaukta",
+    "notifyTitle": "Pranešti {name}",
+    "email": "El. paštas",
+    "noEmail": "(nėra el. pašto)",
+    "sms": "SMS",
+    "noPhone": "(nėra telefono)",
+    "sendNotification": "Siųsti pranešimą",
+    "revokeLink": "Atšaukti nuorodą",
+    "generateLink": "Generuoti viešą nuorodą",
+    "emailSent": "Laiškas išsiųstas",
+    "failedEmail": "Nepavyko išsiųsti laiško",
+    "smsSent": "SMS išsiųsta",
+    "failedSms": "Nepavyko išsiųsti SMS"
   },
   "phone": {
     "pageTitle": "Apžiūros nuotraukos",

+ 4 - 0
messages/nb/common.json

@@ -199,6 +199,10 @@
     "title": "Får ikke kontakt med Torqvoice",
     "description": "Den siste endringen din ble kanskje ikke lagret. Sjekk tilkoblingen og prøv igjen."
   },
+  "shareLink": {
+    "generateFailed": "Kunne ikke opprette lenken. Prøv igjen.",
+    "revokeFailed": "Kunne ikke bekrefte at lenken ble tilbakekalt. Den kan fortsatt virke for alle som har den."
+  },
   "searchableSelect": {
     "choose": "Velg...",
     "search": "Søk...",

+ 13 - 1
messages/nb/inspections.json

@@ -459,7 +459,19 @@
     "copyLink": "Kopier lenke",
     "copied": "Lenken er kopiert",
     "generated": "Delingslenke opprettet",
-    "revoked": "Delingslenke trukket tilbake"
+    "revoked": "Delingslenke trukket tilbake",
+    "notifyTitle": "Varsle {name}",
+    "email": "E-post",
+    "noEmail": "(ingen e-post registrert)",
+    "sms": "SMS",
+    "noPhone": "(ingen telefon registrert)",
+    "sendNotification": "Send varsel",
+    "revokeLink": "Tilbakekall lenke",
+    "generateLink": "Generer offentlig lenke",
+    "emailSent": "E-post sendt",
+    "failedEmail": "Kunne ikke sende e-post",
+    "smsSent": "SMS sendt",
+    "failedSms": "Kunne ikke sende SMS"
   },
   "phone": {
     "pageTitle": "Bilder til kontrollen",

+ 4 - 0
messages/nl/common.json

@@ -199,6 +199,10 @@
     "title": "Torqvoice is niet bereikbaar",
     "description": "Je laatste wijziging is mogelijk niet opgeslagen. Controleer je verbinding en probeer het opnieuw."
   },
+  "shareLink": {
+    "generateFailed": "Kon de link niet aanmaken. Probeer het opnieuw.",
+    "revokeFailed": "Kon niet bevestigen dat de link is ingetrokken. Hij werkt mogelijk nog voor iedereen die hem heeft."
+  },
   "searchableSelect": {
     "choose": "Kiezen...",
     "search": "Zoeken...",

+ 13 - 1
messages/nl/inspections.json

@@ -459,7 +459,19 @@
     "copyLink": "Link kopiëren",
     "copied": "Link gekopieerd naar klembord",
     "generated": "Deellink aangemaakt",
-    "revoked": "Deellink ingetrokken"
+    "revoked": "Deellink ingetrokken",
+    "notifyTitle": "{name} op de hoogte stellen",
+    "email": "E-mail",
+    "noEmail": "(geen e-mail geregistreerd)",
+    "sms": "SMS",
+    "noPhone": "(geen telefoonnummer geregistreerd)",
+    "sendNotification": "Melding verzenden",
+    "revokeLink": "Link intrekken",
+    "generateLink": "Openbare link genereren",
+    "emailSent": "E-mail verzonden",
+    "failedEmail": "E-mail verzenden mislukt",
+    "smsSent": "SMS verzonden",
+    "failedSms": "SMS verzenden mislukt"
   },
   "phone": {
     "pageTitle": "Foto's bij de keuring",

+ 4 - 0
messages/pl/common.json

@@ -199,6 +199,10 @@
     "title": "Nie można połączyć się z Torqvoice",
     "description": "Ostatnia zmiana mogła nie zostać zapisana. Sprawdź połączenie i spróbuj ponownie."
   },
+  "shareLink": {
+    "generateFailed": "Nie udało się utworzyć linku. Spróbuj ponownie.",
+    "revokeFailed": "Nie udało się potwierdzić unieważnienia linku. Może nadal działać dla każdego, kto go ma."
+  },
   "searchableSelect": {
     "choose": "Wybierz...",
     "search": "Szukaj...",

+ 13 - 1
messages/pl/inspections.json

@@ -459,7 +459,19 @@
     "copyLink": "Kopiuj link",
     "copied": "Link skopiowany do schowka",
     "generated": "Link do udostępniania utworzony",
-    "revoked": "Link do udostępniania wycofany"
+    "revoked": "Link do udostępniania wycofany",
+    "notifyTitle": "Powiadom {name}",
+    "email": "E-mail",
+    "noEmail": "(brak adresu e-mail)",
+    "sms": "SMS",
+    "noPhone": "(brak numeru telefonu)",
+    "sendNotification": "Wyślij powiadomienie",
+    "revokeLink": "Unieważnij link",
+    "generateLink": "Wygeneruj publiczny link",
+    "emailSent": "E-mail wysłany",
+    "failedEmail": "Nie udało się wysłać e-maila",
+    "smsSent": "SMS wysłany",
+    "failedSms": "Nie udało się wysłać SMS-a"
   },
   "phone": {
     "pageTitle": "Zdjęcia do przeglądu",

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

@@ -199,6 +199,10 @@
     "title": "Não foi possível conectar ao Torqvoice",
     "description": "Sua última alteração pode não ter sido salva. Verifique sua conexão e tente novamente."
   },
+  "shareLink": {
+    "generateFailed": "Não foi possível criar o link. Tente novamente.",
+    "revokeFailed": "Não foi possível confirmar que o link foi revogado. Ele pode continuar funcionando para quem o tiver."
+  },
   "searchableSelect": {
     "choose": "Escolher...",
     "search": "Buscar...",

+ 13 - 1
messages/pt-BR/inspections.json

@@ -459,7 +459,19 @@
     "copyLink": "Copiar link",
     "copied": "Link copiado para a área de transferência",
     "generated": "Link de compartilhamento criado",
-    "revoked": "Link de compartilhamento revogado"
+    "revoked": "Link de compartilhamento revogado",
+    "notifyTitle": "Notificar {name}",
+    "email": "E-mail",
+    "noEmail": "(sem e-mail cadastrado)",
+    "sms": "SMS",
+    "noPhone": "(sem telefone cadastrado)",
+    "sendNotification": "Enviar notificação",
+    "revokeLink": "Revogar link",
+    "generateLink": "Gerar link público",
+    "emailSent": "E-mail enviado",
+    "failedEmail": "Falha ao enviar o e-mail",
+    "smsSent": "SMS enviado",
+    "failedSms": "Falha ao enviar o SMS"
   },
   "phone": {
     "pageTitle": "Fotos da inspeção",

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

@@ -199,6 +199,10 @@
     "title": "Não foi possível contactar o Torqvoice",
     "description": "A sua última alteração pode não ter sido guardada. Verifique a sua ligação e tente novamente."
   },
+  "shareLink": {
+    "generateFailed": "Não foi possível criar o link. Tente novamente.",
+    "revokeFailed": "Não foi possível confirmar que o link foi revogado. Pode continuar a funcionar para quem o tiver."
+  },
   "searchableSelect": {
     "choose": "Escolher...",
     "search": "Pesquisar...",

+ 13 - 1
messages/pt-PT/inspections.json

@@ -459,7 +459,19 @@
     "copyLink": "Copiar link",
     "copied": "Link copiado para a área de transferência",
     "generated": "Link de partilha criado",
-    "revoked": "Link de partilha revogado"
+    "revoked": "Link de partilha revogado",
+    "notifyTitle": "Notificar {name}",
+    "email": "E-mail",
+    "noEmail": "(sem e-mail registado)",
+    "sms": "SMS",
+    "noPhone": "(sem telefone registado)",
+    "sendNotification": "Enviar notificação",
+    "revokeLink": "Revogar link",
+    "generateLink": "Gerar link público",
+    "emailSent": "E-mail enviado",
+    "failedEmail": "Não foi possível enviar o e-mail",
+    "smsSent": "SMS enviado",
+    "failedSms": "Não foi possível enviar o SMS"
   },
   "phone": {
     "pageTitle": "Fotografias da inspeção",

+ 4 - 0
messages/ru/common.json

@@ -199,6 +199,10 @@
     "title": "Не удалось связаться с Torqvoice",
     "description": "Последнее изменение могло не сохраниться. Проверьте подключение и повторите попытку."
   },
+  "shareLink": {
+    "generateFailed": "Не удалось создать ссылку. Попробуйте ещё раз.",
+    "revokeFailed": "Не удалось подтвердить отзыв ссылки. Она может по-прежнему работать для всех, у кого она есть."
+  },
   "searchableSelect": {
     "choose": "Выберите...",
     "search": "Поиск...",

+ 13 - 1
messages/ru/inspections.json

@@ -459,7 +459,19 @@
     "copyLink": "Копировать ссылку",
     "copied": "Ссылка скопирована в буфер обмена",
     "generated": "Ссылка для доступа создана",
-    "revoked": "Ссылка для доступа отозвана"
+    "revoked": "Ссылка для доступа отозвана",
+    "notifyTitle": "Уведомить {name}",
+    "email": "Эл. почта",
+    "noEmail": "(эл. почта не указана)",
+    "sms": "SMS",
+    "noPhone": "(телефон не указан)",
+    "sendNotification": "Отправить уведомление",
+    "revokeLink": "Отозвать ссылку",
+    "generateLink": "Создать публичную ссылку",
+    "emailSent": "Письмо отправлено",
+    "failedEmail": "Не удалось отправить письмо",
+    "smsSent": "SMS отправлено",
+    "failedSms": "Не удалось отправить SMS"
   },
   "phone": {
     "pageTitle": "Фото к осмотру",

+ 4 - 0
messages/tr/common.json

@@ -199,6 +199,10 @@
     "title": "Torqvoice'a ulaşılamadı",
     "description": "Son değişikliğiniz kaydedilmemiş olabilir. Bağlantınızı kontrol edip tekrar deneyin."
   },
+  "shareLink": {
+    "generateFailed": "Bağlantı oluşturulamadı. Tekrar deneyin.",
+    "revokeFailed": "Bağlantının iptali doğrulanamadı. Bağlantıya sahip olan herkes onu hâlâ açabilir."
+  },
   "searchableSelect": {
     "choose": "Seçin...",
     "search": "Ara...",

+ 13 - 1
messages/tr/inspections.json

@@ -459,7 +459,19 @@
     "copyLink": "Bağlantıyı kopyala",
     "copied": "Bağlantı panoya kopyalandı",
     "generated": "Paylaşım bağlantısı oluşturuldu",
-    "revoked": "Paylaşım bağlantısı geri alındı"
+    "revoked": "Paylaşım bağlantısı geri alındı",
+    "notifyTitle": "{name} Bilgilendir",
+    "email": "E-posta",
+    "noEmail": "(kayıtlı e-posta yok)",
+    "sms": "SMS",
+    "noPhone": "(kayıtlı telefon yok)",
+    "sendNotification": "Bildirim Gönder",
+    "revokeLink": "Bağlantıyı İptal Et",
+    "generateLink": "Herkese Açık Bağlantı Oluştur",
+    "emailSent": "E-posta gönderildi",
+    "failedEmail": "E-posta gönderilemedi",
+    "smsSent": "SMS gönderildi",
+    "failedSms": "SMS gönderilemedi"
   },
   "phone": {
     "pageTitle": "Muayene fotoğrafları",

+ 241 - 0
src/__tests__/features/share-link-revoke.test.tsx

@@ -0,0 +1,241 @@
+/**
+ * A shared link stays on screen until the server has revoked it. The share
+ * dialogs cleared it whatever the action answered, and the Shared Link card
+ * ignored the answer, so a refused revoke (a role without edit rights) or one
+ * that never reached the server told the workshop the link was gone while the
+ * customer's copy still opened the document.
+ */
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import { Component, type ReactNode } from 'react'
+import { toast } from 'sonner'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const revokeQuotePublicLink = vi.fn()
+const generateQuotePublicLink = vi.fn()
+const revokePublicLink = vi.fn()
+const generatePublicLink = vi.fn()
+const revokeInspectionPublicLink = vi.fn()
+const generateInspectionPublicLink = vi.fn()
+const router = { push: vi.fn(), refresh: vi.fn() }
+
+vi.mock('next/navigation', () => ({ useRouter: () => router }))
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
+vi.mock('@/lib/analytics', () => ({ track: vi.fn() }))
+// Async wrappers, as a real server action is: a dropped connection rejects
+// the promise rather than throwing on the call.
+vi.mock('@/features/quotes/Actions/quoteShareActions', () => ({
+  revokeQuotePublicLink: async (id: string) => revokeQuotePublicLink(id),
+  generateQuotePublicLink: async (id: string) => generateQuotePublicLink(id),
+}))
+vi.mock('@/features/vehicles/Actions/serviceActions', () => ({
+  revokePublicLink: async (id: string) => revokePublicLink(id),
+  generatePublicLink: async (id: string) => generatePublicLink(id),
+}))
+vi.mock('@/features/inspections/Actions/inspectionShareActions', () => ({
+  revokeInspectionPublicLink: async (id: string) => revokeInspectionPublicLink(id),
+  generateInspectionPublicLink: async (id: string) => generateInspectionPublicLink(id),
+}))
+vi.mock('@/features/email/Actions/emailActions', () => ({
+  sendQuoteEmail: vi.fn(),
+  sendInvoiceEmail: vi.fn(),
+  sendInspectionEmail: vi.fn(),
+}))
+vi.mock('@/features/sms/Actions/smsActions', () => ({
+  sendSmsToCustomer: vi.fn(),
+  getSmsTemplates: vi.fn(),
+}))
+vi.mock('@/features/email/Actions/emailPreferenceActions', () => ({
+  getAttachPdfDefault: async () => ({ success: true, data: { attachPdf: true } }),
+}))
+
+import { QuoteShareDialog } from '@/features/quotes/Components/QuoteShareDialog'
+import { ShareDialog } from '@/features/vehicles/Components/service-detail/ShareDialog'
+import { InspectionShareDialog } from '@/features/inspections/Components/InspectionShareDialog'
+import { SharedLinkCard } from '@/components/shared-link-card'
+
+const REVOKE_FAILED =
+  "Couldn't confirm the link was revoked. It may still work for anyone who has it."
+const GENERATE_FAILED = "Couldn't create the link. Try again."
+const DROPPED = () => {
+  throw new TypeError('Failed to fetch')
+}
+
+/** Stands in for the app's error page, which a transition's throw would reach. */
+class ErrorPage extends Component<{ children: ReactNode }, { failed: boolean }> {
+  state = { failed: false }
+  static getDerivedStateFromError() {
+    return { failed: true }
+  }
+  render() {
+    return this.state.failed ? <p>Error page</p> : this.props.children
+  }
+}
+
+async function click(name: string) {
+  await act(async () => {
+    fireEvent.click(screen.getByRole('button', { name }))
+  })
+}
+
+function linkField() {
+  return screen.queryByDisplayValue(/\/share\//)
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+})
+
+describe('quote share dialog', () => {
+  function renderDialog() {
+    render(
+      <QuoteShareDialog
+        open
+        onOpenChange={vi.fn()}
+        quoteId="q1"
+        organizationId="org1"
+        initialToken="tok"
+      />
+    )
+  }
+
+  it('keeps the link and says so when the server refuses the revoke', async () => {
+    revokeQuotePublicLink.mockResolvedValue({ success: false, error: 'Forbidden' })
+    renderDialog()
+    await click('Revoke Link')
+    expect(linkField()).toBeInTheDocument()
+    expect(toast.error).toHaveBeenCalledWith(REVOKE_FAILED)
+    expect(screen.getByRole('button', { name: 'Revoke Link' })).toBeEnabled()
+  })
+
+  it('keeps the link when the revoke never reached the server', async () => {
+    revokeQuotePublicLink.mockImplementation(DROPPED)
+    renderDialog()
+    await click('Revoke Link')
+    expect(linkField()).toBeInTheDocument()
+    expect(toast.error).toHaveBeenCalledWith(REVOKE_FAILED)
+  })
+
+  it('removes the link once the server has revoked it', async () => {
+    revokeQuotePublicLink.mockResolvedValue({ success: true, data: { revoked: true } })
+    renderDialog()
+    await click('Revoke Link')
+    expect(linkField()).not.toBeInTheDocument()
+    expect(screen.getByRole('button', { name: 'Generate Public Link' })).toBeInTheDocument()
+    expect(toast.error).not.toHaveBeenCalled()
+  })
+})
+
+describe('invoice share dialog', () => {
+  function renderDialog(token: string | null) {
+    render(
+      <ShareDialog
+        open
+        onOpenChange={vi.fn()}
+        recordId="r1"
+        organizationId="org1"
+        initialToken={token}
+      />
+    )
+  }
+
+  it('keeps the link and says so when the server refuses the revoke', async () => {
+    revokePublicLink.mockResolvedValue({ success: false, error: 'Forbidden' })
+    renderDialog('tok')
+    await click('Revoke Link')
+    expect(linkField()).toBeInTheDocument()
+    expect(toast.error).toHaveBeenCalledWith(REVOKE_FAILED)
+    expect(screen.getByRole('button', { name: 'Revoke Link' })).toBeEnabled()
+  })
+
+  it('keeps the link when the revoke never reached the server', async () => {
+    revokePublicLink.mockImplementation(DROPPED)
+    renderDialog('tok')
+    await click('Revoke Link')
+    expect(linkField()).toBeInTheDocument()
+    expect(toast.error).toHaveBeenCalledWith(REVOKE_FAILED)
+  })
+
+  it('says so when a new link cannot be created, and lets it be tried again', async () => {
+    generatePublicLink.mockImplementation(DROPPED)
+    renderDialog(null)
+    await click('Generate Public Link')
+    expect(toast.error).toHaveBeenCalledWith(GENERATE_FAILED)
+    expect(screen.getByRole('button', { name: 'Generate Public Link' })).toBeEnabled()
+  })
+})
+
+describe('inspection share dialog', () => {
+  function renderDialog() {
+    render(
+      <ErrorPage>
+        <InspectionShareDialog
+          open
+          onOpenChange={vi.fn()}
+          inspectionId="i1"
+          organizationId="org1"
+          publicToken="tok"
+        />
+      </ErrorPage>
+    )
+  }
+
+  it('stays on the page and keeps the link when the revoke never reached the server', async () => {
+    revokeInspectionPublicLink.mockImplementation(DROPPED)
+    renderDialog()
+    await click('Revoke Link')
+    expect(screen.queryByText('Error page')).not.toBeInTheDocument()
+    expect(linkField()).toBeInTheDocument()
+    expect(toast.error).toHaveBeenCalledWith(REVOKE_FAILED)
+  })
+
+  it('keeps the link when the server refuses the revoke', async () => {
+    revokeInspectionPublicLink.mockResolvedValue({ success: false, error: 'Forbidden' })
+    renderDialog()
+    await click('Revoke Link')
+    expect(linkField()).toBeInTheDocument()
+    expect(toast.error).toHaveBeenCalledWith(REVOKE_FAILED)
+  })
+})
+
+describe('shared link card', () => {
+  function renderCard(onRevoke: () => Promise<boolean>) {
+    render(
+      <SharedLinkCard
+        publicToken="tok"
+        organizationId="org1"
+        type="invoice"
+        sharedAt={null}
+        viewCount={0}
+        lastViewedAt={null}
+        onRevoke={onRevoke}
+      />
+    )
+    // The revoke button is the one without a label of its own.
+    return screen.getAllByRole('button').find((b) => !b.getAttribute('aria-label'))!
+  }
+
+  it('says the link still works when the revoke was refused', async () => {
+    const button = renderCard(async () => false)
+    await act(async () => {
+      fireEvent.click(button)
+    })
+    expect(toast.error).toHaveBeenCalledWith(REVOKE_FAILED)
+    expect(button).toBeEnabled()
+  })
+
+  it('says the link still works when the revoke never reached the server', async () => {
+    const button = renderCard(async () => DROPPED())
+    await act(async () => {
+      fireEvent.click(button)
+    })
+    expect(toast.error).toHaveBeenCalledWith(REVOKE_FAILED)
+  })
+
+  it('stays quiet when the revoke went through', async () => {
+    const button = renderCard(async () => true)
+    await act(async () => {
+      fireEvent.click(button)
+    })
+    expect(toast.error).not.toHaveBeenCalled()
+  })
+})

+ 11 - 7
src/components/shared-link-card.tsx

@@ -4,6 +4,7 @@ import { SectionFrame } from '@/components/section-frame'
 import { useState } from 'react'
 import { Link2, Copy, Check, Trash2, Loader2 } from 'lucide-react'
 import { useTranslations } from 'next-intl'
+import { toast } from 'sonner'
 import { useFormatDate } from '@/lib/use-format-date'
 
 interface SharedLinkCardProps {
@@ -13,7 +14,8 @@ interface SharedLinkCardProps {
   sharedAt: Date | null
   viewCount: number
   lastViewedAt: Date | null
-  onRevoke?: () => Promise<void>
+  /** Revokes the link and says whether the server did; false keeps the card. */
+  onRevoke?: () => Promise<boolean>
 }
 
 export function SharedLinkCard({
@@ -26,6 +28,7 @@ export function SharedLinkCard({
   onRevoke,
 }: SharedLinkCardProps) {
   const t = useTranslations(type === 'quote' ? 'quotes' : 'service')
+  const tc = useTranslations('common')
   // The workshop's own zone and formats, so the server's render and the
   // browser's agree instead of each printing its own clock.
   const { formatDate, formatDateTime } = useFormatDate()
@@ -46,14 +49,15 @@ export function SharedLinkCard({
     }
   }
 
+  // A refusal leaves the link working, and a request that got no answer may
+  // or may not have revoked it, so the card says the revoke could not be
+  // confirmed rather than letting the workshop think the link is gone.
   const handleRevoke = async () => {
-    if (!onRevoke) return
+    if (!onRevoke || revoking) return
     setRevoking(true)
-    try {
-      await onRevoke()
-    } finally {
-      setRevoking(false)
-    }
+    const revoked = await onRevoke().catch(() => false)
+    if (!revoked) toast.error(tc('shareLink.revokeFailed'))
+    setRevoking(false)
   }
 
   const hasViews = viewCount > 0

+ 9 - 5
src/features/email/Components/AttachPdfOption.tsx

@@ -22,11 +22,15 @@ export function useAttachPdf(open: boolean) {
     if (!open) return
     chosen.current = false
     let cancelled = false
-    getAttachPdfDefault().then((result) => {
-      if (!cancelled && !chosen.current && result.success && result.data) {
-        setAttachPdfState(result.data.attachPdf)
-      }
-    })
+    // A background read nobody asked for: when it cannot reach the server the
+    // box keeps its default rather than raising the lost-connection notice.
+    getAttachPdfDefault()
+      .catch(() => null)
+      .then((result) => {
+        if (!cancelled && !chosen.current && result?.success && result.data) {
+          setAttachPdfState(result.data.attachPdf)
+        }
+      })
     return () => {
       cancelled = true
     }

+ 28 - 24
src/features/inspections/Components/InspectionShareDialog.tsx

@@ -55,6 +55,7 @@ export function InspectionShareDialog({
   emailEnabled = false,
 }: InspectionShareDialogProps) {
   const t = useTranslations('inspections.shareDialog')
+  const tc = useTranslations('common')
   const router = useRouter()
   const [isPending, startTransition] = useTransition()
   const [copied, setCopied] = useState(false)
@@ -71,28 +72,31 @@ export function InspectionShareDialog({
     ? `${typeof window !== 'undefined' ? window.location.origin : ''}/share/inspection/${organizationId}/${token}`
     : null
 
+  // An action that cannot reach the server throws instead of answering.
+  // Inside a transition that would replace the page with the error page, so
+  // each call is caught and reported the way a refusal is.
   const handleGenerate = () => {
     startTransition(async () => {
-      const result = await generateInspectionPublicLink(inspectionId)
-      if (result.success && result.data) {
+      const result = await generateInspectionPublicLink(inspectionId).catch(() => null)
+      if (result?.success && result.data) {
         setToken(result.data.token)
         toast.success(t('generated'))
         router.refresh()
       } else {
-        toast.error(result.error || 'Failed to generate link')
+        toast.error(tc('shareLink.generateFailed'))
       }
     })
   }
 
   const handleRevoke = () => {
     startTransition(async () => {
-      const result = await revokeInspectionPublicLink(inspectionId)
-      if (result.success) {
+      const result = await revokeInspectionPublicLink(inspectionId).catch(() => null)
+      if (result?.success) {
         setToken(null)
         toast.success(t('revoked'))
         router.refresh()
       } else {
-        toast.error(result.error || 'Failed to revoke link')
+        toast.error(tc('shareLink.revokeFailed'))
       }
     })
   }
@@ -117,20 +121,20 @@ export function InspectionShareDialog({
         inspectionId,
         recipientEmail: customer.email!,
         attachPdf,
-      })
-      if (res.success) {
+      }).catch(() => null)
+      if (res?.success) {
         track('document:email_send', { document: 'inspection' })
-        results.push('Email sent')
+        results.push(t('emailSent'))
         setNotifyEmail(false)
       } else {
         failed = true
-        if (!handleGated(res)) toast.error(res.error || 'Failed to send email')
+        if (!res || !handleGated(res)) toast.error(res?.error || t('failedEmail'))
       }
     }
 
     if (notifySms && hasPhone) {
-      const tplResult = await getSmsTemplates()
-      const tplData = tplResult.success && tplResult.data ? tplResult.data : null
+      const tplResult = await getSmsTemplates().catch(() => null)
+      const tplData = tplResult?.success && tplResult.data ? tplResult.data : null
       const tpl =
         tplData?.templates[SETTING_KEYS.SMS_TEMPLATE_INSPECTION_READY] ||
         SMS_TEMPLATE_DEFAULTS[SETTING_KEYS.SMS_TEMPLATE_INSPECTION_READY]
@@ -145,13 +149,13 @@ export function InspectionShareDialog({
         body,
         relatedEntityType: 'inspection',
         relatedEntityId: inspectionId,
-      })
-      if (res.success) {
-        results.push('SMS sent')
+      }).catch(() => null)
+      if (res?.success) {
+        results.push(t('smsSent'))
         setNotifySms(false)
       } else {
         failed = true
-        toast.error(res.error || 'Failed to send SMS')
+        toast.error(res?.error || t('failedSms'))
       }
     }
 
@@ -194,7 +198,7 @@ export function InspectionShareDialog({
               {/* Notify customer */}
               {customer && (emailEnabled || smsEnabled) && (
                 <div className="space-y-3 rounded-lg border p-3">
-                  <p className="text-sm font-medium">Notify {customer.name}</p>
+                  <p className="text-sm font-medium">{t('notifyTitle', { name: customer.name })}</p>
                   <div className="space-y-2">
                     {emailEnabled && (
                       <div className="flex items-center gap-2">
@@ -209,8 +213,8 @@ export function InspectionShareDialog({
                           className={`flex items-center gap-1.5 text-sm ${!hasEmail ? 'text-muted-foreground/50' : ''}`}
                         >
                           <Mail className="h-3.5 w-3.5" />
-                          Email
-                          {!hasEmail && <span className="text-xs">(no email on file)</span>}
+                          {t('email')}
+                          {!hasEmail && <span className="text-xs">{t('noEmail')}</span>}
                         </Label>
                       </div>
                     )}
@@ -237,8 +241,8 @@ export function InspectionShareDialog({
                           className={`flex items-center gap-1.5 text-sm ${!hasPhone ? 'text-muted-foreground/50' : ''}`}
                         >
                           <MessageSquare className="h-3.5 w-3.5" />
-                          SMS
-                          {!hasPhone && <span className="text-xs">(no phone on file)</span>}
+                          {t('sms')}
+                          {!hasPhone && <span className="text-xs">{t('noPhone')}</span>}
                         </Label>
                       </div>
                     )}
@@ -252,7 +256,7 @@ export function InspectionShareDialog({
                     className="w-full"
                   >
                     {sending && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
-                    Send Notification
+                    {t('sendNotification')}
                   </Button>
                 </div>
               )}
@@ -268,7 +272,7 @@ export function InspectionShareDialog({
                 ) : (
                   <Unlink className="mr-2 h-4 w-4" />
                 )}
-                Revoke Link
+                {t('revokeLink')}
               </Button>
             </>
           ) : (
@@ -278,7 +282,7 @@ export function InspectionShareDialog({
               ) : (
                 <Link2 className="mr-2 h-4 w-4" />
               )}
-              Generate Share Link
+              {t('generateLink')}
             </Button>
           )}
         </div>

+ 3 - 2
src/features/quotes/Components/QuotePageClient.tsx

@@ -256,8 +256,9 @@ export function QuotePageClient({
     [state]
   )
   const handleRevoke = useCallback(async () => {
-    await revokeQuotePublicLink(quote.id)
-    router.refresh()
+    const result = await revokeQuotePublicLink(quote.id)
+    if (result.success) router.refresh()
+    return result.success
   }, [quote.id, router])
 
   // A locked quote offers no editing, rather than letting someone retype a

+ 1 - 1
src/features/quotes/Components/QuoteRightColumn.tsx

@@ -35,7 +35,7 @@ interface QuoteRightColumnProps {
   distanceUnit: string
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   t: (key: string, values?: any) => string
-  onRevoke: () => Promise<void>
+  onRevoke: () => Promise<boolean>
   /**
    * Why the quote is locked, or null when it edits. A lock disables the fields
    * that edit the quote; its actions, and any status that keeps it locked, stay

+ 22 - 4
src/features/quotes/Components/QuoteShareDialog.tsx

@@ -52,12 +52,14 @@ export function QuoteShareDialog({
 }: QuoteShareDialogProps) {
   const [publicToken, setPublicToken] = useState<string | null>(initialToken)
   const [generatingLink, setGeneratingLink] = useState(false)
+  const [revoking, setRevoking] = useState(false)
   const [copied, setCopied] = useState(false)
   const [notifyEmail, setNotifyEmail] = useState(false)
   const [attachPdf, setAttachPdf] = useAttachPdf(open)
   const [notifySms, setNotifySms] = useState(false)
   const [sending, setSending] = useState(false)
   const t = useTranslations('quotes')
+  const tc = useTranslations('common')
 
   const hasEmail = !!customer?.email
   const hasPhone = !!customer?.phone
@@ -67,20 +69,34 @@ export function QuoteShareDialog({
       ? `${typeof window !== 'undefined' ? window.location.origin : ''}/share/quote/${organizationId}/${publicToken}`
       : null
 
+  // An action that cannot reach the server throws instead of answering, so
+  // both of these catch it and say the same thing a refusal does.
   const handleGenerate = async () => {
     setGeneratingLink(true)
-    const result = await generateQuotePublicLink(quoteId)
-    if (result.success && result.data) {
+    const result = await generateQuotePublicLink(quoteId).catch(() => null)
+    if (result?.success && result.data) {
       setPublicToken(result.data.token)
       // Handing over a link is a way of sending the quote.
       onSent?.()
+    } else {
+      toast.error(tc('shareLink.generateFailed'))
     }
     setGeneratingLink(false)
   }
 
+  // The link stays on screen until the server has revoked it: clearing it on
+  // a refusal would tell the workshop it is gone while the customer's copy
+  // still opens the quote.
   const handleRevoke = async () => {
-    await revokeQuotePublicLink(quoteId)
-    setPublicToken(null)
+    if (revoking) return
+    setRevoking(true)
+    const result = await revokeQuotePublicLink(quoteId).catch(() => null)
+    if (result?.success) {
+      setPublicToken(null)
+    } else {
+      toast.error(tc('shareLink.revokeFailed'))
+    }
+    setRevoking(false)
   }
 
   const handleCopy = () => {
@@ -256,7 +272,9 @@ export function QuoteShareDialog({
                 size="sm"
                 className="text-destructive hover:text-destructive"
                 onClick={handleRevoke}
+                disabled={revoking}
               >
+                {revoking && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
                 {t('shareDialog.revokeLink')}
               </Button>
             </>

+ 15 - 6
src/features/vehicles/Components/service-detail/ShareDialog.tsx

@@ -48,6 +48,7 @@ export function ShareDialog({
   emailEnabled = false,
 }: ShareDialogProps) {
   const t = useTranslations('service.share')
+  const tc = useTranslations('common')
   const [publicToken, setPublicToken] = useState<string | null>(initialToken)
   const [generatingLink, setGeneratingLink] = useState(false)
   const [revoking, setRevoking] = useState(false)
@@ -65,26 +66,34 @@ export function ShareDialog({
       ? `${typeof window !== 'undefined' ? window.location.origin : ''}/share/invoice/${organizationId}/${publicToken}`
       : null
 
+  // An action that cannot reach the server throws instead of answering, so
+  // both of these catch it and say the same thing a refusal does.
   const handleGenerate = async () => {
     setGeneratingLink(true)
-    const result = await generatePublicLink(recordId)
-    if (result.success && result.data) {
+    const result = await generatePublicLink(recordId).catch(() => null)
+    if (result?.success && result.data) {
       setPublicToken(result.data.token)
       // Handing over a link is a way of sending the invoice.
       onSent?.()
+    } else {
+      toast.error(tc('shareLink.generateFailed'))
     }
     setGeneratingLink(false)
   }
 
+  // The link stays on screen until the server has revoked it: clearing it on
+  // a refusal would tell the workshop it is gone while the customer's copy
+  // still opens the invoice.
   const handleRevoke = async () => {
     if (revoking) return
     setRevoking(true)
-    try {
-      await revokePublicLink(recordId)
+    const result = await revokePublicLink(recordId).catch(() => null)
+    if (result?.success) {
       setPublicToken(null)
-    } finally {
-      setRevoking(false)
+    } else {
+      toast.error(tc('shareLink.revokeFailed'))
     }
+    setRevoking(false)
   }
 
   const handleCopy = () => {

+ 3 - 2
src/features/vehicles/Components/service-page/modern/ModernDetails.tsx

@@ -559,8 +559,9 @@ export function ModernDetails(props: ModernDetailsProps) {
                     viewCount={record.viewCount}
                     lastViewedAt={record.lastViewedAt}
                     onRevoke={async () => {
-                      await revokePublicLink(record.id)
-                      router.refresh()
+                      const result = await revokePublicLink(record.id)
+                      if (result.success) router.refresh()
+                      return result.success
                     }}
                   />
                 )}