diff --git a/messages/de.json b/messages/de.json index e4a12f169..c0f43f7b2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -540,6 +540,11 @@ "errorRateLimit": "Zu viele Versuche. Warte ein paar Minuten und versuche es erneut.", "errorFailed": "Das hat nicht geklappt. Versuche es gleich noch einmal.", "errorOffline": "Das hat den Server nicht erreicht. Prüfe deine Verbindung und versuche es erneut." + }, + "inviteInvalid": { + "title": "Einladung ungültig", + "description": "Dieser Einladungslink ist unvollständig oder falsch kopiert. Bitte die Person, die dich eingeladen hat, um einen neuen Link.", + "toLogin": "Zur Anmeldung" } }, "dashboard": { @@ -8713,7 +8718,11 @@ }, "course": "Phase {n}", "courseRange": "{start} bis {end}", - "courseSince": "seit {start}" + "courseSince": "seit {start}", + "unavailable": { + "title": "Link abgelaufen oder ungültig", + "description": "Dieser Freigabelink ist abgelaufen, wurde zurückgezogen oder ist unvollständig. Bitte die Person, die ihn geteilt hat, um einen neuen Link." + } }, "cycle": { "symptomCategory": { diff --git a/messages/en.json b/messages/en.json index 083e46de2..2b4d08d79 100644 --- a/messages/en.json +++ b/messages/en.json @@ -540,6 +540,11 @@ "errorRateLimit": "Too many attempts. Wait a few minutes and try again.", "errorFailed": "That did not work. Try again in a moment.", "errorOffline": "That did not reach the server. Check your connection and try again." + }, + "inviteInvalid": { + "title": "Invitation invalid", + "description": "This invitation link is incomplete or was copied wrongly. Ask the person who invited you for a new link.", + "toLogin": "Go to sign in" } }, "dashboard": { @@ -8713,7 +8718,11 @@ }, "course": "Course {n}", "courseRange": "{start} to {end}", - "courseSince": "since {start}" + "courseSince": "since {start}", + "unavailable": { + "title": "Link expired or invalid", + "description": "This share link has expired, was revoked, or is incomplete. Ask the person who shared it for a new link." + } }, "cycle": { "symptomCategory": { diff --git a/messages/es.json b/messages/es.json index fc0f84eeb..2c0901a0b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -540,6 +540,11 @@ "errorRateLimit": "Demasiados intentos. Espera unos minutos y vuelve a intentarlo.", "errorFailed": "No ha funcionado. Vuelve a intentarlo en un momento.", "errorOffline": "No se llegó al servidor. Comprueba tu conexión y vuelve a intentarlo." + }, + "inviteInvalid": { + "title": "Invitación no válida", + "description": "Este enlace de invitación está incompleto o se copió mal. Pide un enlace nuevo a la persona que te invitó.", + "toLogin": "Ir a iniciar sesión" } }, "dashboard": { @@ -8713,7 +8718,11 @@ }, "course": "Tratamiento {n}", "courseRange": "del {start} al {end}", - "courseSince": "desde el {start}" + "courseSince": "desde el {start}", + "unavailable": { + "title": "Enlace caducado o no válido", + "description": "Este enlace para compartir ha caducado, se ha revocado o está incompleto. Pide un enlace nuevo a la persona que lo compartió." + } }, "cycle": { "symptomCategory": { diff --git a/messages/fr.json b/messages/fr.json index d6f04c335..6627cc1c4 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -540,6 +540,11 @@ "errorRateLimit": "Trop de tentatives. Attendez quelques minutes puis réessayez.", "errorFailed": "Cela n'a pas fonctionné. Réessayez dans un instant.", "errorOffline": "Le serveur n'a pas été atteint. Vérifiez votre connexion et réessayez." + }, + "inviteInvalid": { + "title": "Invitation invalide", + "description": "Ce lien d’invitation est incomplet ou a été mal copié. Demandez un nouveau lien à la personne qui vous a invité.", + "toLogin": "Aller à la connexion" } }, "dashboard": { @@ -8713,7 +8718,11 @@ }, "course": "Traitement {n}", "courseRange": "du {start} au {end}", - "courseSince": "depuis le {start}" + "courseSince": "depuis le {start}", + "unavailable": { + "title": "Lien expiré ou invalide", + "description": "Ce lien de partage a expiré, a été révoqué ou est incomplet. Demandez un nouveau lien à la personne qui l’a partagé." + } }, "cycle": { "symptomCategory": { diff --git a/messages/it.json b/messages/it.json index dacbf1728..5dc38aa73 100644 --- a/messages/it.json +++ b/messages/it.json @@ -540,6 +540,11 @@ "errorRateLimit": "Troppi tentativi. Attendi qualche minuto e riprova.", "errorFailed": "Non ha funzionato. Riprova tra un momento.", "errorOffline": "Il server non è stato raggiunto. Controlla la connessione e riprova." + }, + "inviteInvalid": { + "title": "Invito non valido", + "description": "Questo link di invito è incompleto o è stato copiato male. Chiedi un nuovo link alla persona che ti ha invitato.", + "toLogin": "Vai all’accesso" } }, "dashboard": { @@ -8713,7 +8718,11 @@ }, "course": "Ciclo {n}", "courseRange": "dal {start} al {end}", - "courseSince": "dal {start}" + "courseSince": "dal {start}", + "unavailable": { + "title": "Link scaduto o non valido", + "description": "Questo link di condivisione è scaduto, è stato revocato o è incompleto. Chiedi un nuovo link alla persona che lo ha condiviso." + } }, "cycle": { "symptomCategory": { diff --git a/messages/ko.json b/messages/ko.json index af007eb90..9ddb7604e 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -540,6 +540,11 @@ "errorRateLimit": "시도 횟수가 너무 많습니다. 몇 분 후 다시 시도하세요.", "errorFailed": "처리하지 못했습니다. 잠시 후 다시 시도하세요.", "errorOffline": "서버에 연결하지 못했습니다. 연결을 확인하고 다시 시도하세요." + }, + "inviteInvalid": { + "title": "유효하지 않은 초대예요", + "description": "이 초대 링크가 불완전하거나 잘못 복사되었어요. 초대한 분께 새 링크를 요청해 주세요.", + "toLogin": "로그인으로 이동" } }, "dashboard": { @@ -8713,7 +8718,11 @@ }, "course": "복용 기간 {n}", "courseRange": "{start}부터 {end}까지", - "courseSince": "{start}부터" + "courseSince": "{start}부터", + "unavailable": { + "title": "링크가 만료되었거나 유효하지 않아요", + "description": "이 공유 링크는 만료되었거나 취소되었거나 불완전해요. 공유한 분께 새 링크를 요청해 주세요." + } }, "cycle": { "symptomCategory": { diff --git a/messages/pl.json b/messages/pl.json index 53e772597..de6cd01aa 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -540,6 +540,11 @@ "errorRateLimit": "Zbyt wiele prób. Odczekaj kilka minut i spróbuj ponownie.", "errorFailed": "Nie udało się. Spróbuj ponownie za chwilę.", "errorOffline": "Nie udało się połączyć z serwerem. Sprawdź połączenie i spróbuj ponownie." + }, + "inviteInvalid": { + "title": "Nieprawidłowe zaproszenie", + "description": "Ten link z zaproszeniem jest niekompletny lub został źle skopiowany. Poproś osobę, która cię zaprosiła, o nowy link.", + "toLogin": "Przejdź do logowania" } }, "dashboard": { @@ -8713,7 +8718,11 @@ }, "course": "Kuracja {n}", "courseRange": "od {start} do {end}", - "courseSince": "od {start}" + "courseSince": "od {start}", + "unavailable": { + "title": "Link wygasł lub jest nieprawidłowy", + "description": "Ten link udostępniania wygasł, został cofnięty lub jest niekompletny. Poproś osobę, która go udostępniła, o nowy link." + } }, "cycle": { "symptomCategory": { diff --git a/src/app/c/[token]/not-found.tsx b/src/app/c/[token]/not-found.tsx new file mode 100644 index 000000000..9aa8e7138 --- /dev/null +++ b/src/app/c/[token]/not-found.tsx @@ -0,0 +1,40 @@ +import { LinkIcon } from "lucide-react"; + +import { resolveShareViewLocale } from "@/lib/clinician-share/request-locale"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; + +/** + * What a practice sees when a shared link no longer opens: expired, revoked, + * mistyped. The page still answers 404 for every failure class alike (the + * share page's `notFound()`), so a probe learns nothing from it; what changes + * is the copy. The app's own 404 said "this page does not exist" and offered + * a dashboard the reader has no account for. This one says what happened in + * the reader's terms and what to do, and links nowhere. + */ +export default async function ClinicianShareNotFound() { + const locale = await resolveShareViewLocale(); + const { t } = getServerTranslator(locale); + + return ( +
+ +
+

+ {t("clinicianView.unavailable.title")} +

+

+ {t("clinicianView.unavailable.description")} +

+
+
+ ); +} diff --git a/src/app/invite/[token]/__tests__/page.test.tsx b/src/app/invite/[token]/__tests__/page.test.tsx index 2615afd64..e0670167c 100644 --- a/src/app/invite/[token]/__tests__/page.test.tsx +++ b/src/app/invite/[token]/__tests__/page.test.tsx @@ -24,6 +24,11 @@ vi.mock("next/navigation", () => ({ redirect: (href: string) => redirectMock(href), })); +vi.mock("@/lib/i18n/server-locale", () => ({ + resolveServerLocale: async () => "en", +})); + +import { renderToStaticMarkup } from "react-dom/server"; import InviteLandingPage from "../page"; beforeEach(() => { @@ -56,9 +61,10 @@ describe(" universal-link landing", () => { expect(url.searchParams.get("invite")).toBe(VALID_TOKEN); }); - it("drops a malformed token to plain register (no enumeration oracle)", async () => { - // Wrong prefix, wrong length, and outright garbage all land on the - // identical target — the page never reveals whether a token exists. + it("names a malformed link as invalid instead of opening registration", async () => { + // Wrong prefix, wrong length, and outright garbage all render the same + // invalid-invite state. The shape is public, so this reveals nothing + // about which well-formed tokens exist; those still go on to register. for (const bad of [ "not-a-token", `hlk_${"a".repeat(64)}`, @@ -66,8 +72,14 @@ describe(" universal-link landing", () => { `hlv_${"G".repeat(64)}`, ]) { redirectMock.mockClear(); - const href = await runRedirect(bad); - expect(href).toBe("/auth/register"); + const node = await InviteLandingPage({ + params: Promise.resolve({ token: bad }), + }); + expect(redirectMock).not.toHaveBeenCalled(); + const html = renderToStaticMarkup(node); + expect(html).toContain('data-slot="invite-invalid"'); + expect(html).toContain("Invitation invalid"); + expect(html).not.toContain("/auth/register"); } }); }); diff --git a/src/app/invite/[token]/page.tsx b/src/app/invite/[token]/page.tsx index 236f6be7b..dfa4c14d8 100644 --- a/src/app/invite/[token]/page.tsx +++ b/src/app/invite/[token]/page.tsx @@ -1,6 +1,10 @@ +import Link from "next/link"; import { redirect } from "next/navigation"; +import { MailX } from "lucide-react"; import { looksLikeInviteToken } from "@/lib/auth/invite-token"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; /** * v1.17.0 — invite universal-link landing (iOS #16). @@ -41,7 +45,40 @@ export default async function InviteLandingPage({ redirect(`/auth/register?invite=${encodeURIComponent(token)}`); } - // Malformed token: behave exactly like a visitor with no invite. No DB - // hit, no leak of whether any token exists. - redirect("/auth/register"); + // Malformed token: say so. This used to fall through to the plain + // registration form, which on an open instance invited a stranger to sign + // up and on a closed one refused them without saying why. The shape check + // is public knowledge and needs no database, so naming a malformed link as + // invalid leaks nothing about which tokens exist: a well-formed unknown + // token still goes on to register, where the signup decides. + const locale = await resolveServerLocale(); + const { t } = getServerTranslator(locale); + return ( +
+ +
+

+ {t("auth.inviteInvalid.title")} +

+

+ {t("auth.inviteInvalid.description")} +

+
+ + {t("auth.inviteInvalid.toLogin")} + +
+ ); }