Explorar el Código

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 hace 1 día
padre
commit
e5ee3868b5
Se han modificado 35 ficheros con 554 adiciones y 64 borrados
  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",
     "title": "Torqvoice ist nicht erreichbar",
     "description": "Ihre letzte Änderung wurde möglicherweise nicht gespeichert. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut."
     "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": {
   "searchableSelect": {
     "choose": "Auswählen...",
     "choose": "Auswählen...",
     "search": "Suchen...",
     "search": "Suchen...",

+ 13 - 1
messages/de/inspections.json

@@ -459,7 +459,19 @@
     "copyLink": "Link kopieren",
     "copyLink": "Link kopieren",
     "copied": "Link in die Zwischenablage kopiert",
     "copied": "Link in die Zwischenablage kopiert",
     "generated": "Freigabelink erstellt",
     "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": {
   "phone": {
     "pageTitle": "Fotos zur Prüfung",
     "pageTitle": "Fotos zur Prüfung",

+ 4 - 0
messages/en/common.json

@@ -199,6 +199,10 @@
     "title": "Couldn't reach Torqvoice",
     "title": "Couldn't reach Torqvoice",
     "description": "Your last change may not have been saved. Check your connection and try again."
     "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": {
   "searchableSelect": {
     "choose": "Choose...",
     "choose": "Choose...",
     "search": "Search...",
     "search": "Search...",

+ 13 - 1
messages/en/inspections.json

@@ -459,7 +459,19 @@
     "copyLink": "Copy link",
     "copyLink": "Copy link",
     "copied": "Link copied to clipboard",
     "copied": "Link copied to clipboard",
     "generated": "Share link generated",
     "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": {
   "phone": {
     "pageTitle": "Inspection photos",
     "pageTitle": "Inspection photos",

+ 4 - 0
messages/es/common.json

@@ -199,6 +199,10 @@
     "title": "No se pudo conectar con Torqvoice",
     "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."
     "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": {
   "searchableSelect": {
     "choose": "Elegir...",
     "choose": "Elegir...",
     "search": "Buscar...",
     "search": "Buscar...",

+ 13 - 1
messages/es/inspections.json

@@ -459,7 +459,19 @@
     "copyLink": "Copiar enlace",
     "copyLink": "Copiar enlace",
     "copied": "Enlace copiado al portapapeles",
     "copied": "Enlace copiado al portapapeles",
     "generated": "Enlace de compartición creado",
     "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": {
   "phone": {
     "pageTitle": "Fotos de la inspección",
     "pageTitle": "Fotos de la inspección",

+ 4 - 0
messages/fr/common.json

@@ -199,6 +199,10 @@
     "title": "Impossible de joindre Torqvoice",
     "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."
     "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": {
   "searchableSelect": {
     "choose": "Choisir...",
     "choose": "Choisir...",
     "search": "Rechercher...",
     "search": "Rechercher...",

+ 13 - 1
messages/fr/inspections.json

@@ -459,7 +459,19 @@
     "copyLink": "Copier le lien",
     "copyLink": "Copier le lien",
     "copied": "Lien copié dans le presse-papiers",
     "copied": "Lien copié dans le presse-papiers",
     "generated": "Lien de partage créé",
     "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": {
   "phone": {
     "pageTitle": "Photos de l'inspection",
     "pageTitle": "Photos de l'inspection",

+ 4 - 0
messages/it/common.json

@@ -199,6 +199,10 @@
     "title": "Impossibile raggiungere Torqvoice",
     "title": "Impossibile raggiungere Torqvoice",
     "description": "L'ultima modifica potrebbe non essere stata salvata. Controlla la connessione e riprova."
     "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": {
   "searchableSelect": {
     "choose": "Scegli...",
     "choose": "Scegli...",
     "search": "Cerca...",
     "search": "Cerca...",

+ 13 - 1
messages/it/inspections.json

@@ -459,7 +459,19 @@
     "copyLink": "Copia link",
     "copyLink": "Copia link",
     "copied": "Link copiato negli appunti",
     "copied": "Link copiato negli appunti",
     "generated": "Link di condivisione creato",
     "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": {
   "phone": {
     "pageTitle": "Foto dell'ispezione",
     "pageTitle": "Foto dell'ispezione",

+ 4 - 0
messages/lt/common.json

@@ -199,6 +199,10 @@
     "title": "Nepavyko pasiekti Torqvoice",
     "title": "Nepavyko pasiekti Torqvoice",
     "description": "Paskutinis pakeitimas galėjo būti neišsaugotas. Patikrinkite ryšį ir bandykite dar kartą."
     "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": {
   "searchableSelect": {
     "choose": "Pasirinkite...",
     "choose": "Pasirinkite...",
     "search": "Ieškoti...",
     "search": "Ieškoti...",

+ 13 - 1
messages/lt/inspections.json

@@ -459,7 +459,19 @@
     "copyLink": "Kopijuoti nuorodą",
     "copyLink": "Kopijuoti nuorodą",
     "copied": "Nuoroda nukopijuota į iškarpinę",
     "copied": "Nuoroda nukopijuota į iškarpinę",
     "generated": "Bendrinimo nuoroda sukurta",
     "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": {
   "phone": {
     "pageTitle": "Apžiūros nuotraukos",
     "pageTitle": "Apžiūros nuotraukos",

+ 4 - 0
messages/nb/common.json

@@ -199,6 +199,10 @@
     "title": "Får ikke kontakt med Torqvoice",
     "title": "Får ikke kontakt med Torqvoice",
     "description": "Den siste endringen din ble kanskje ikke lagret. Sjekk tilkoblingen og prøv igjen."
     "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": {
   "searchableSelect": {
     "choose": "Velg...",
     "choose": "Velg...",
     "search": "Søk...",
     "search": "Søk...",

+ 13 - 1
messages/nb/inspections.json

@@ -459,7 +459,19 @@
     "copyLink": "Kopier lenke",
     "copyLink": "Kopier lenke",
     "copied": "Lenken er kopiert",
     "copied": "Lenken er kopiert",
     "generated": "Delingslenke opprettet",
     "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": {
   "phone": {
     "pageTitle": "Bilder til kontrollen",
     "pageTitle": "Bilder til kontrollen",

+ 4 - 0
messages/nl/common.json

@@ -199,6 +199,10 @@
     "title": "Torqvoice is niet bereikbaar",
     "title": "Torqvoice is niet bereikbaar",
     "description": "Je laatste wijziging is mogelijk niet opgeslagen. Controleer je verbinding en probeer het opnieuw."
     "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": {
   "searchableSelect": {
     "choose": "Kiezen...",
     "choose": "Kiezen...",
     "search": "Zoeken...",
     "search": "Zoeken...",

+ 13 - 1
messages/nl/inspections.json

@@ -459,7 +459,19 @@
     "copyLink": "Link kopiëren",
     "copyLink": "Link kopiëren",
     "copied": "Link gekopieerd naar klembord",
     "copied": "Link gekopieerd naar klembord",
     "generated": "Deellink aangemaakt",
     "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": {
   "phone": {
     "pageTitle": "Foto's bij de keuring",
     "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",
     "title": "Nie można połączyć się z Torqvoice",
     "description": "Ostatnia zmiana mogła nie zostać zapisana. Sprawdź połączenie i spróbuj ponownie."
     "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": {
   "searchableSelect": {
     "choose": "Wybierz...",
     "choose": "Wybierz...",
     "search": "Szukaj...",
     "search": "Szukaj...",

+ 13 - 1
messages/pl/inspections.json

@@ -459,7 +459,19 @@
     "copyLink": "Kopiuj link",
     "copyLink": "Kopiuj link",
     "copied": "Link skopiowany do schowka",
     "copied": "Link skopiowany do schowka",
     "generated": "Link do udostępniania utworzony",
     "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": {
   "phone": {
     "pageTitle": "Zdjęcia do przeglądu",
     "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",
     "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."
     "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": {
   "searchableSelect": {
     "choose": "Escolher...",
     "choose": "Escolher...",
     "search": "Buscar...",
     "search": "Buscar...",

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

@@ -459,7 +459,19 @@
     "copyLink": "Copiar link",
     "copyLink": "Copiar link",
     "copied": "Link copiado para a área de transferência",
     "copied": "Link copiado para a área de transferência",
     "generated": "Link de compartilhamento criado",
     "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": {
   "phone": {
     "pageTitle": "Fotos da inspeção",
     "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",
     "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."
     "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": {
   "searchableSelect": {
     "choose": "Escolher...",
     "choose": "Escolher...",
     "search": "Pesquisar...",
     "search": "Pesquisar...",

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

@@ -459,7 +459,19 @@
     "copyLink": "Copiar link",
     "copyLink": "Copiar link",
     "copied": "Link copiado para a área de transferência",
     "copied": "Link copiado para a área de transferência",
     "generated": "Link de partilha criado",
     "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": {
   "phone": {
     "pageTitle": "Fotografias da inspeção",
     "pageTitle": "Fotografias da inspeção",

+ 4 - 0
messages/ru/common.json

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

+ 13 - 1
messages/ru/inspections.json

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

+ 4 - 0
messages/tr/common.json

@@ -199,6 +199,10 @@
     "title": "Torqvoice'a ulaşılamadı",
     "title": "Torqvoice'a ulaşılamadı",
     "description": "Son değişikliğiniz kaydedilmemiş olabilir. Bağlantınızı kontrol edip tekrar deneyin."
     "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": {
   "searchableSelect": {
     "choose": "Seçin...",
     "choose": "Seçin...",
     "search": "Ara...",
     "search": "Ara...",

+ 13 - 1
messages/tr/inspections.json

@@ -459,7 +459,19 @@
     "copyLink": "Bağlantıyı kopyala",
     "copyLink": "Bağlantıyı kopyala",
     "copied": "Bağlantı panoya kopyalandı",
     "copied": "Bağlantı panoya kopyalandı",
     "generated": "Paylaşım bağlantısı oluşturuldu",
     "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": {
   "phone": {
     "pageTitle": "Muayene fotoğrafları",
     "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 { useState } from 'react'
 import { Link2, Copy, Check, Trash2, Loader2 } from 'lucide-react'
 import { Link2, Copy, Check, Trash2, Loader2 } from 'lucide-react'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
+import { toast } from 'sonner'
 import { useFormatDate } from '@/lib/use-format-date'
 import { useFormatDate } from '@/lib/use-format-date'
 
 
 interface SharedLinkCardProps {
 interface SharedLinkCardProps {
@@ -13,7 +14,8 @@ interface SharedLinkCardProps {
   sharedAt: Date | null
   sharedAt: Date | null
   viewCount: number
   viewCount: number
   lastViewedAt: Date | null
   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({
 export function SharedLinkCard({
@@ -26,6 +28,7 @@ export function SharedLinkCard({
   onRevoke,
   onRevoke,
 }: SharedLinkCardProps) {
 }: SharedLinkCardProps) {
   const t = useTranslations(type === 'quote' ? 'quotes' : 'service')
   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
   // The workshop's own zone and formats, so the server's render and the
   // browser's agree instead of each printing its own clock.
   // browser's agree instead of each printing its own clock.
   const { formatDate, formatDateTime } = useFormatDate()
   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 () => {
   const handleRevoke = async () => {
-    if (!onRevoke) return
+    if (!onRevoke || revoking) return
     setRevoking(true)
     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
   const hasViews = viewCount > 0

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

@@ -22,11 +22,15 @@ export function useAttachPdf(open: boolean) {
     if (!open) return
     if (!open) return
     chosen.current = false
     chosen.current = false
     let cancelled = 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 () => {
     return () => {
       cancelled = true
       cancelled = true
     }
     }

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

@@ -55,6 +55,7 @@ export function InspectionShareDialog({
   emailEnabled = false,
   emailEnabled = false,
 }: InspectionShareDialogProps) {
 }: InspectionShareDialogProps) {
   const t = useTranslations('inspections.shareDialog')
   const t = useTranslations('inspections.shareDialog')
+  const tc = useTranslations('common')
   const router = useRouter()
   const router = useRouter()
   const [isPending, startTransition] = useTransition()
   const [isPending, startTransition] = useTransition()
   const [copied, setCopied] = useState(false)
   const [copied, setCopied] = useState(false)
@@ -71,28 +72,31 @@ export function InspectionShareDialog({
     ? `${typeof window !== 'undefined' ? window.location.origin : ''}/share/inspection/${organizationId}/${token}`
     ? `${typeof window !== 'undefined' ? window.location.origin : ''}/share/inspection/${organizationId}/${token}`
     : null
     : 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 = () => {
   const handleGenerate = () => {
     startTransition(async () => {
     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)
         setToken(result.data.token)
         toast.success(t('generated'))
         toast.success(t('generated'))
         router.refresh()
         router.refresh()
       } else {
       } else {
-        toast.error(result.error || 'Failed to generate link')
+        toast.error(tc('shareLink.generateFailed'))
       }
       }
     })
     })
   }
   }
 
 
   const handleRevoke = () => {
   const handleRevoke = () => {
     startTransition(async () => {
     startTransition(async () => {
-      const result = await revokeInspectionPublicLink(inspectionId)
-      if (result.success) {
+      const result = await revokeInspectionPublicLink(inspectionId).catch(() => null)
+      if (result?.success) {
         setToken(null)
         setToken(null)
         toast.success(t('revoked'))
         toast.success(t('revoked'))
         router.refresh()
         router.refresh()
       } else {
       } else {
-        toast.error(result.error || 'Failed to revoke link')
+        toast.error(tc('shareLink.revokeFailed'))
       }
       }
     })
     })
   }
   }
@@ -117,20 +121,20 @@ export function InspectionShareDialog({
         inspectionId,
         inspectionId,
         recipientEmail: customer.email!,
         recipientEmail: customer.email!,
         attachPdf,
         attachPdf,
-      })
-      if (res.success) {
+      }).catch(() => null)
+      if (res?.success) {
         track('document:email_send', { document: 'inspection' })
         track('document:email_send', { document: 'inspection' })
-        results.push('Email sent')
+        results.push(t('emailSent'))
         setNotifyEmail(false)
         setNotifyEmail(false)
       } else {
       } else {
         failed = true
         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) {
     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 =
       const tpl =
         tplData?.templates[SETTING_KEYS.SMS_TEMPLATE_INSPECTION_READY] ||
         tplData?.templates[SETTING_KEYS.SMS_TEMPLATE_INSPECTION_READY] ||
         SMS_TEMPLATE_DEFAULTS[SETTING_KEYS.SMS_TEMPLATE_INSPECTION_READY]
         SMS_TEMPLATE_DEFAULTS[SETTING_KEYS.SMS_TEMPLATE_INSPECTION_READY]
@@ -145,13 +149,13 @@ export function InspectionShareDialog({
         body,
         body,
         relatedEntityType: 'inspection',
         relatedEntityType: 'inspection',
         relatedEntityId: inspectionId,
         relatedEntityId: inspectionId,
-      })
-      if (res.success) {
-        results.push('SMS sent')
+      }).catch(() => null)
+      if (res?.success) {
+        results.push(t('smsSent'))
         setNotifySms(false)
         setNotifySms(false)
       } else {
       } else {
         failed = true
         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 */}
               {/* Notify customer */}
               {customer && (emailEnabled || smsEnabled) && (
               {customer && (emailEnabled || smsEnabled) && (
                 <div className="space-y-3 rounded-lg border p-3">
                 <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">
                   <div className="space-y-2">
                     {emailEnabled && (
                     {emailEnabled && (
                       <div className="flex items-center gap-2">
                       <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' : ''}`}
                           className={`flex items-center gap-1.5 text-sm ${!hasEmail ? 'text-muted-foreground/50' : ''}`}
                         >
                         >
                           <Mail className="h-3.5 w-3.5" />
                           <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>
                         </Label>
                       </div>
                       </div>
                     )}
                     )}
@@ -237,8 +241,8 @@ export function InspectionShareDialog({
                           className={`flex items-center gap-1.5 text-sm ${!hasPhone ? 'text-muted-foreground/50' : ''}`}
                           className={`flex items-center gap-1.5 text-sm ${!hasPhone ? 'text-muted-foreground/50' : ''}`}
                         >
                         >
                           <MessageSquare className="h-3.5 w-3.5" />
                           <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>
                         </Label>
                       </div>
                       </div>
                     )}
                     )}
@@ -252,7 +256,7 @@ export function InspectionShareDialog({
                     className="w-full"
                     className="w-full"
                   >
                   >
                     {sending && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
                     {sending && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
-                    Send Notification
+                    {t('sendNotification')}
                   </Button>
                   </Button>
                 </div>
                 </div>
               )}
               )}
@@ -268,7 +272,7 @@ export function InspectionShareDialog({
                 ) : (
                 ) : (
                   <Unlink className="mr-2 h-4 w-4" />
                   <Unlink className="mr-2 h-4 w-4" />
                 )}
                 )}
-                Revoke Link
+                {t('revokeLink')}
               </Button>
               </Button>
             </>
             </>
           ) : (
           ) : (
@@ -278,7 +282,7 @@ export function InspectionShareDialog({
               ) : (
               ) : (
                 <Link2 className="mr-2 h-4 w-4" />
                 <Link2 className="mr-2 h-4 w-4" />
               )}
               )}
-              Generate Share Link
+              {t('generateLink')}
             </Button>
             </Button>
           )}
           )}
         </div>
         </div>

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

@@ -256,8 +256,9 @@ export function QuotePageClient({
     [state]
     [state]
   )
   )
   const handleRevoke = useCallback(async () => {
   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])
   }, [quote.id, router])
 
 
   // A locked quote offers no editing, rather than letting someone retype a
   // 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
   distanceUnit: string
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   t: (key: string, values?: any) => string
   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
    * 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
    * 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) {
 }: QuoteShareDialogProps) {
   const [publicToken, setPublicToken] = useState<string | null>(initialToken)
   const [publicToken, setPublicToken] = useState<string | null>(initialToken)
   const [generatingLink, setGeneratingLink] = useState(false)
   const [generatingLink, setGeneratingLink] = useState(false)
+  const [revoking, setRevoking] = useState(false)
   const [copied, setCopied] = useState(false)
   const [copied, setCopied] = useState(false)
   const [notifyEmail, setNotifyEmail] = useState(false)
   const [notifyEmail, setNotifyEmail] = useState(false)
   const [attachPdf, setAttachPdf] = useAttachPdf(open)
   const [attachPdf, setAttachPdf] = useAttachPdf(open)
   const [notifySms, setNotifySms] = useState(false)
   const [notifySms, setNotifySms] = useState(false)
   const [sending, setSending] = useState(false)
   const [sending, setSending] = useState(false)
   const t = useTranslations('quotes')
   const t = useTranslations('quotes')
+  const tc = useTranslations('common')
 
 
   const hasEmail = !!customer?.email
   const hasEmail = !!customer?.email
   const hasPhone = !!customer?.phone
   const hasPhone = !!customer?.phone
@@ -67,20 +69,34 @@ export function QuoteShareDialog({
       ? `${typeof window !== 'undefined' ? window.location.origin : ''}/share/quote/${organizationId}/${publicToken}`
       ? `${typeof window !== 'undefined' ? window.location.origin : ''}/share/quote/${organizationId}/${publicToken}`
       : null
       : 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 () => {
   const handleGenerate = async () => {
     setGeneratingLink(true)
     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)
       setPublicToken(result.data.token)
       // Handing over a link is a way of sending the quote.
       // Handing over a link is a way of sending the quote.
       onSent?.()
       onSent?.()
+    } else {
+      toast.error(tc('shareLink.generateFailed'))
     }
     }
     setGeneratingLink(false)
     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 () => {
   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 = () => {
   const handleCopy = () => {
@@ -256,7 +272,9 @@ export function QuoteShareDialog({
                 size="sm"
                 size="sm"
                 className="text-destructive hover:text-destructive"
                 className="text-destructive hover:text-destructive"
                 onClick={handleRevoke}
                 onClick={handleRevoke}
+                disabled={revoking}
               >
               >
+                {revoking && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
                 {t('shareDialog.revokeLink')}
                 {t('shareDialog.revokeLink')}
               </Button>
               </Button>
             </>
             </>

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

@@ -48,6 +48,7 @@ export function ShareDialog({
   emailEnabled = false,
   emailEnabled = false,
 }: ShareDialogProps) {
 }: ShareDialogProps) {
   const t = useTranslations('service.share')
   const t = useTranslations('service.share')
+  const tc = useTranslations('common')
   const [publicToken, setPublicToken] = useState<string | null>(initialToken)
   const [publicToken, setPublicToken] = useState<string | null>(initialToken)
   const [generatingLink, setGeneratingLink] = useState(false)
   const [generatingLink, setGeneratingLink] = useState(false)
   const [revoking, setRevoking] = useState(false)
   const [revoking, setRevoking] = useState(false)
@@ -65,26 +66,34 @@ export function ShareDialog({
       ? `${typeof window !== 'undefined' ? window.location.origin : ''}/share/invoice/${organizationId}/${publicToken}`
       ? `${typeof window !== 'undefined' ? window.location.origin : ''}/share/invoice/${organizationId}/${publicToken}`
       : null
       : 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 () => {
   const handleGenerate = async () => {
     setGeneratingLink(true)
     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)
       setPublicToken(result.data.token)
       // Handing over a link is a way of sending the invoice.
       // Handing over a link is a way of sending the invoice.
       onSent?.()
       onSent?.()
+    } else {
+      toast.error(tc('shareLink.generateFailed'))
     }
     }
     setGeneratingLink(false)
     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 () => {
   const handleRevoke = async () => {
     if (revoking) return
     if (revoking) return
     setRevoking(true)
     setRevoking(true)
-    try {
-      await revokePublicLink(recordId)
+    const result = await revokePublicLink(recordId).catch(() => null)
+    if (result?.success) {
       setPublicToken(null)
       setPublicToken(null)
-    } finally {
-      setRevoking(false)
+    } else {
+      toast.error(tc('shareLink.revokeFailed'))
     }
     }
+    setRevoking(false)
   }
   }
 
 
   const handleCopy = () => {
   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}
                     viewCount={record.viewCount}
                     lastViewedAt={record.lastViewedAt}
                     lastViewedAt={record.lastViewedAt}
                     onRevoke={async () => {
                     onRevoke={async () => {
-                      await revokePublicLink(record.id)
-                      router.refresh()
+                      const result = await revokePublicLink(record.id)
+                      if (result.success) router.refresh()
+                      return result.success
                     }}
                     }}
                   />
                   />
                 )}
                 )}