feat(claim): offer the identity provider on the claim page and name the way in to guardians

The claim page shows the password form, a continue-with-provider button, or
both, as the preview names them, and explains a refused single-sign-on claim
when the callback sends the browser back. The guardian's handover panel says
how the person will sign in.
This commit is contained in:
Marc-André Bombeck committed 2026-10-15 23:34:38 +02:00
1 parent 3fd6bd6081
commit 0ebb61343e
13 files changed
+469 -85

No files matched your search

+18 -4
View File
@@ -528,7 +528,7 @@
"loading": "Dein Link wird geprüft…",
"invalid": "Dieser Link funktioniert nicht mehr",
"invalidHint": "Er ist unbekannt, abgelaufen, zurückgezogen oder schon benutzt. Bitte die Person, die ihn dir gegeben hat, um einen neuen.",
"oidcOnly": "Dieser Server erlaubt nur Single Sign-on, deshalb lässt sich ein Profil hier noch nicht übernehmen.",
"oidcOnly": "Dieser Server erlaubt nur Single Sign-on. Fahre stattdessen mit deinem Identitätsanbieter fort.",
"signedInTitle": "Melde dich zuerst ab",
"signedInBody": "Du bist als {username} angemeldet. Dieser Link richtet eine Anmeldung für jemand anderen ein, melde dich also ab, bevor du ihn benutzt.",
"signOut": "Abmelden und weiter",
@@ -545,7 +545,19 @@
"errorTaken": "Dieser Benutzername oder diese E-Mail-Adresse ist schon vergeben.",
"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."
"errorOffline": "Das hat den Server nicht erreicht. Prüfe deine Verbindung und versuche es erneut.",
"ssoContinue": "Weiter mit {provider}",
"ssoStarting": "Weiterleitung…",
"ssoHint": "Ab dann meldest du dich hier mit deinem Konto bei {provider} an.",
"ssoDivider": "oder",
"ssoFallbackName": "Single Sign-on",
"errorTitle": "Die Übernahme hat nicht geklappt",
"errorSubjectBound": "Diese Anmeldung gehört schon zu einem anderen Konto auf diesem Server. Öffne den Link noch einmal und nimm ein anderes Konto bei deinem Identitätsanbieter.",
"errorEmailTaken": "Dein Identitätsanbieter hat eine E-Mail-Adresse geliefert, die hier schon ein anderes Konto nutzt. Öffne den Link noch einmal und nimm ein anderes Konto, oder frag die Person, die den Server betreibt.",
"errorUsernameTaken": "Jemand hat diesen Benutzernamen inzwischen vergeben. Öffne den Link noch einmal und wähle einen anderen.",
"errorSignedIn": "Bei der Rückkehr warst du angemeldet. Melde dich ab und öffne den Link noch einmal.",
"errorSso": "Die Anmeldung bei deinem Identitätsanbieter hat nicht funktioniert. Öffne den Link noch einmal, um es erneut zu versuchen.",
"signInInstead": "Bei diesem Konto anmelden"
},
"inviteInvalid": {
"title": "Einladung ungültig",
@@ -11900,9 +11912,11 @@
"revokeFailed": "Der Link ließ sich nicht zurückziehen. Versuche es erneut.",
"errorFailed": "Der Übergabelink ließ sich nicht erstellen.",
"errorRosterChanged": "Inzwischen hat sich geändert, wer die Akte betreut. Schließe das hier und öffne es erneut.",
"oidcOnly": "Ein Profil zu übergeben ist nicht möglich, solange dieser Server nur Single Sign-on erlaubt.",
"oidcOnly": "Ein Profil zu übergeben ist auf diesem Server nicht möglich.",
"loadError": "Der Stand der Übergabe ließ sich nicht laden.",
"entryHint": "Wenn {name} die Akte selbst führen möchte."
"entryHint": "Wenn {name} die Akte selbst führen möchte.",
"methodSso": "{name} meldet sich beim Einlösen des Links über Single Sign-on an.",
"methodBoth": "{name} kann ein Passwort festlegen oder sich über Single Sign-on anmelden."
}
},
"toast": {
+18 -4
View File
@@ -528,7 +528,7 @@
"loading": "Checking your link…",
"invalid": "This link no longer works",
"invalidHint": "It is unknown, expired, withdrawn or already used. Ask the person who sent it for a new one.",
"oidcOnly": "This server only allows single sign-on, so a profile cannot be taken over here yet.",
"oidcOnly": "This server only allows single sign-on. Continue with your identity provider instead.",
"signedInTitle": "Sign out first",
"signedInBody": "You are signed in as {username}. This link sets up a sign-in for someone else, so sign out before you use it.",
"signOut": "Sign out and continue",
@@ -545,7 +545,19 @@
"errorTaken": "That username or email address is already in use.",
"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."
"errorOffline": "That did not reach the server. Check your connection and try again.",
"ssoContinue": "Continue with {provider}",
"ssoStarting": "Redirecting…",
"ssoHint": "From then on you sign in here with your {provider} account.",
"ssoDivider": "or",
"ssoFallbackName": "single sign-on",
"errorTitle": "The handover did not go through",
"errorSubjectBound": "This sign-in already belongs to another account on this server. Open the link again and use a different account at your identity provider.",
"errorEmailTaken": "Your identity provider gave an email address that another account here already uses. Open the link again and use a different account, or ask the server's administrator.",
"errorUsernameTaken": "Someone took that username in the meantime. Open the link again and choose another one.",
"errorSignedIn": "You were signed in when you came back. Sign out, then open the link again.",
"errorSso": "Signing in with your identity provider did not work. Open the link again to try once more.",
"signInInstead": "Sign in to that account"
},
"inviteInvalid": {
"title": "Invitation invalid",
@@ -11900,9 +11912,11 @@
"revokeFailed": "The link could not be withdrawn. Try again.",
"errorFailed": "The handover link could not be created.",
"errorRosterChanged": "The list of guardians changed in the meantime. Close this and open it again.",
"oidcOnly": "Handing over a profile is not available while this server allows single sign-on only.",
"oidcOnly": "Handing over a profile is not available on this server.",
"loadError": "The handover status could not be loaded.",
"entryHint": "When {name} is ready to look after this record themselves."
"entryHint": "When {name} is ready to look after this record themselves.",
"methodSso": "{name} signs in through single sign-on when they use the link.",
"methodBoth": "{name} can set a password or sign in through single sign-on."
}
},
"toast": {
+18 -4
View File
@@ -528,7 +528,7 @@
"loading": "Comprobando tu enlace…",
"invalid": "Este enlace ya no funciona",
"invalidHint": "Es desconocido, ha caducado, se ha retirado o ya se ha usado. Pide uno nuevo a quien te lo envió.",
"oidcOnly": "Este servidor solo permite el inicio de sesión único, así que aquí todavía no se puede asumir un perfil.",
"oidcOnly": "Este servidor solo permite el inicio de sesión único. Continúa con tu proveedor de identidad.",
"signedInTitle": "Cierra la sesión primero",
"signedInBody": "Has iniciado sesión como {username}. Este enlace crea un acceso para otra persona, así que cierra la sesión antes de usarlo.",
"signOut": "Cerrar sesión y continuar",
@@ -545,7 +545,19 @@
"errorTaken": "Ese nombre de usuario o correo electrónico ya está en uso.",
"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."
"errorOffline": "No se llegó al servidor. Comprueba tu conexión y vuelve a intentarlo.",
"ssoContinue": "Continuar con {provider}",
"ssoStarting": "Redirigiendo…",
"ssoHint": "A partir de entonces inicias sesión aquí con tu cuenta de {provider}.",
"ssoDivider": "o",
"ssoFallbackName": "inicio de sesión único",
"errorTitle": "La entrega no se ha completado",
"errorSubjectBound": "Este inicio de sesión ya pertenece a otra cuenta de este servidor. Vuelve a abrir el enlace y usa otra cuenta en tu proveedor de identidad.",
"errorEmailTaken": "Tu proveedor de identidad ha enviado una dirección de correo que ya usa otra cuenta aquí. Vuelve a abrir el enlace y usa otra cuenta, o pregunta a quien administra el servidor.",
"errorUsernameTaken": "Alguien ha cogido ese nombre de usuario mientras tanto. Vuelve a abrir el enlace y elige otro.",
"errorSignedIn": "Tenías una sesión iniciada al volver. Cierra la sesión y vuelve a abrir el enlace.",
"errorSso": "No se ha podido iniciar sesión con tu proveedor de identidad. Vuelve a abrir el enlace para intentarlo otra vez.",
"signInInstead": "Iniciar sesión en esa cuenta"
},
"inviteInvalid": {
"title": "Invitación no válida",
@@ -11900,9 +11912,11 @@
"revokeFailed": "No se pudo retirar el enlace. Vuelve a intentarlo.",
"errorFailed": "No se pudo crear el enlace de entrega.",
"errorRosterChanged": "Mientras tanto ha cambiado quién se ocupa del historial. Cierra esto y vuelve a abrirlo.",
"oidcOnly": "No se puede entregar un perfil mientras este servidor solo permita el inicio de sesión único.",
"oidcOnly": "En este servidor no se puede entregar un perfil.",
"loadError": "No se pudo cargar el estado de la entrega.",
"entryHint": "Cuando {name} quiera llevar este historial por su cuenta."
"entryHint": "Cuando {name} quiera llevar este historial por su cuenta.",
"methodSso": "{name} inicia sesión con el inicio de sesión único cuando usa el enlace.",
"methodBoth": "{name} puede elegir una contraseña o iniciar sesión con el inicio de sesión único."
}
},
"toast": {
+18 -4
View File
@@ -528,7 +528,7 @@
"loading": "Vérification de votre lien…",
"invalid": "Ce lien ne fonctionne plus",
"invalidHint": "Il est inconnu, expiré, retiré ou déjà utilisé. Demandez-en un nouveau à la personne qui vous l'a envoyé.",
"oidcOnly": "Ce serveur n'autorise que l'authentification unique, un profil ne peut donc pas encore être repris ici.",
"oidcOnly": "Ce serveur n'autorise que l'authentification unique. Continuez plutôt avec votre fournisseur d'identité.",
"signedInTitle": "Déconnectez-vous d'abord",
"signedInBody": "Vous êtes connecté en tant que {username}. Ce lien crée une connexion pour quelqu'un d'autre, déconnectez-vous donc avant de l'utiliser.",
"signOut": "Se déconnecter et continuer",
@@ -545,7 +545,19 @@
"errorTaken": "Ce nom d'utilisateur ou cette adresse e-mail est déjà utilisé.",
"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."
"errorOffline": "Le serveur n'a pas été atteint. Vérifiez votre connexion et réessayez.",
"ssoContinue": "Continuer avec {provider}",
"ssoStarting": "Redirection…",
"ssoHint": "Ensuite, vous vous connectez ici avec votre compte {provider}.",
"ssoDivider": "ou",
"ssoFallbackName": "l'authentification unique",
"errorTitle": "La transmission n'a pas abouti",
"errorSubjectBound": "Cette connexion appartient déjà à un autre compte sur ce serveur. Rouvrez le lien et utilisez un autre compte chez votre fournisseur d'identité.",
"errorEmailTaken": "Votre fournisseur d'identité a transmis une adresse e-mail déjà utilisée par un autre compte ici. Rouvrez le lien et utilisez un autre compte, ou contactez la personne qui administre le serveur.",
"errorUsernameTaken": "Quelqu'un a pris ce nom d'utilisateur entre-temps. Rouvrez le lien et choisissez-en un autre.",
"errorSignedIn": "Vous étiez connecté à votre retour. Déconnectez-vous, puis rouvrez le lien.",
"errorSso": "La connexion avec votre fournisseur d'identité n'a pas fonctionné. Rouvrez le lien pour réessayer.",
"signInInstead": "Se connecter à ce compte"
},
"inviteInvalid": {
"title": "Invitation invalide",
@@ -11900,9 +11912,11 @@
"revokeFailed": "Le lien n'a pas pu être retiré. Réessayez.",
"errorFailed": "Le lien de transmission n'a pas pu être créé.",
"errorRosterChanged": "Entre-temps, les personnes qui s'occupent du dossier ont changé. Fermez ceci et rouvrez-le.",
"oidcOnly": "Transmettre un profil n'est pas possible tant que ce serveur n'autorise que l'authentification unique.",
"oidcOnly": "Transmettre un profil n'est pas possible sur ce serveur.",
"loadError": "L'état de la transmission n'a pas pu être chargé.",
"entryHint": "Quand {name} souhaite tenir ce dossier de façon autonome."
"entryHint": "Quand {name} souhaite tenir ce dossier de façon autonome.",
"methodSso": "{name} se connecte par authentification unique en utilisant le lien.",
"methodBoth": "{name} peut définir un mot de passe ou se connecter par authentification unique."
}
},
"toast": {
+18 -4
View File
@@ -528,7 +528,7 @@
"loading": "Controllo del link…",
"invalid": "Questo link non funziona più",
"invalidHint": "È sconosciuto, scaduto, ritirato o già usato. Chiedine uno nuovo a chi te l'ha inviato.",
"oidcOnly": "Questo server consente solo il single sign-on, quindi qui non è ancora possibile prendere in carico un profilo.",
"oidcOnly": "Questo server consente solo il single sign-on. Continua invece con il tuo provider di identità.",
"signedInTitle": "Prima esci",
"signedInBody": "Hai effettuato l'accesso come {username}. Questo link crea un accesso per un'altra persona, quindi esci prima di usarlo.",
"signOut": "Esci e continua",
@@ -545,7 +545,19 @@
"errorTaken": "Questo nome utente o indirizzo email è già in uso.",
"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."
"errorOffline": "Il server non è stato raggiunto. Controlla la connessione e riprova.",
"ssoContinue": "Continua con {provider}",
"ssoStarting": "Reindirizzamento…",
"ssoHint": "Da quel momento accedi qui con il tuo account {provider}.",
"ssoDivider": "oppure",
"ssoFallbackName": "single sign-on (SSO)",
"errorTitle": "La consegna non è andata a buon fine",
"errorSubjectBound": "Questo accesso appartiene già a un altro account su questo server. Riapri il link e usa un altro account presso il tuo provider di identità.",
"errorEmailTaken": "Il tuo provider di identità ha fornito un indirizzo email che qui usa già un altro account. Riapri il link e usa un altro account, oppure chiedi a chi amministra il server.",
"errorUsernameTaken": "Nel frattempo qualcuno ha preso quel nome utente. Riapri il link e scegline un altro.",
"errorSignedIn": "Al ritorno avevi già effettuato l'accesso. Esci, poi riapri il link.",
"errorSso": "L'accesso con il tuo provider di identità non è riuscito. Riapri il link per riprovare.",
"signInInstead": "Accedi a quell'account"
},
"inviteInvalid": {
"title": "Invito non valido",
@@ -11900,9 +11912,11 @@
"revokeFailed": "Impossibile ritirare il link. Riprova.",
"errorFailed": "Impossibile creare il link di consegna.",
"errorRosterChanged": "Nel frattempo è cambiato chi si occupa della cartella. Chiudi e riapri.",
"oidcOnly": "Non è possibile consegnare un profilo finché questo server consente solo il single sign-on.",
"oidcOnly": "Su questo server non è possibile consegnare un profilo.",
"loadError": "Impossibile caricare lo stato della consegna.",
"entryHint": "Quando {name} vuole tenere questa cartella in autonomia."
"entryHint": "Quando {name} vuole tenere questa cartella in autonomia.",
"methodSso": "{name} accede con il single sign-on quando usa il link.",
"methodBoth": "{name} può impostare una password oppure accedere con il single sign-on."
}
},
"toast": {
+18 -4
View File
@@ -528,7 +528,7 @@
"loading": "링크를 확인하는 중…",
"invalid": "이 링크는 더 이상 작동하지 않습니다",
"invalidHint": "알 수 없거나, 만료되었거나, 철회되었거나, 이미 사용된 링크입니다. 보낸 사람에게 새 링크를 요청하세요.",
"oidcOnly": "이 서버는 통합 로그인(SSO)만 허용하므로 아직 여기서 프로필을 넘겨받을 수 없습니다.",
"oidcOnly": "이 서버는 통합 로그인(SSO)만 허용합니다. 대신 ID 공급자로 계속하세요.",
"signedInTitle": "먼저 로그아웃하세요",
"signedInBody": "{username} 계정으로 로그인되어 있습니다. 이 링크는 다른 사람의 로그인을 만드는 링크이므로 사용하기 전에 로그아웃하세요.",
"signOut": "로그아웃하고 계속",
@@ -545,7 +545,19 @@
"errorTaken": "이미 사용 중인 사용자 이름 또는 이메일 주소입니다.",
"errorRateLimit": "시도 횟수가 너무 많습니다. 몇 분 후 다시 시도하세요.",
"errorFailed": "처리하지 못했습니다. 잠시 후 다시 시도하세요.",
"errorOffline": "서버에 연결하지 못했습니다. 연결을 확인하고 다시 시도하세요."
"errorOffline": "서버에 연결하지 못했습니다. 연결을 확인하고 다시 시도하세요.",
"ssoContinue": "{provider}(으)로 계속",
"ssoStarting": "이동하는 중…",
"ssoHint": "이후에는 {provider} 계정으로 여기에 로그인합니다.",
"ssoDivider": "또는",
"ssoFallbackName": "통합 로그인(SSO)",
"errorTitle": "넘겨받기를 완료하지 못했습니다",
"errorSubjectBound": "이 로그인은 이미 이 서버의 다른 계정에 연결되어 있습니다. 링크를 다시 열고 ID 공급자에서 다른 계정을 사용하세요.",
"errorEmailTaken": "ID 공급자가 보낸 이메일 주소를 이 서버의 다른 계정이 이미 사용하고 있습니다. 링크를 다시 열고 다른 계정을 사용하거나 서버 관리자에게 문의하세요.",
"errorUsernameTaken": "그 사이에 다른 사람이 이 사용자 이름을 사용했습니다. 링크를 다시 열고 다른 이름을 선택하세요.",
"errorSignedIn": "돌아왔을 때 이미 로그인되어 있었습니다. 로그아웃한 다음 링크를 다시 여세요.",
"errorSso": "ID 공급자로 로그인하지 못했습니다. 링크를 다시 열어 한 번 더 시도하세요.",
"signInInstead": "그 계정으로 로그인"
},
"inviteInvalid": {
"title": "유효하지 않은 초대예요",
@@ -11900,9 +11912,11 @@
"revokeFailed": "링크를 철회하지 못했습니다. 다시 시도하세요.",
"errorFailed": "넘겨주기 링크를 만들지 못했습니다.",
"errorRosterChanged": "그 사이 기록을 돌보는 사람이 바뀌었습니다. 닫았다가 다시 여세요.",
"oidcOnly": "이 서버가 통합 로그인(SSO)만 허용하는 동안에는 프로필을 넘겨줄 수 없습니다.",
"oidcOnly": "이 서버에서는 프로필을 넘겨줄 수 없습니다.",
"loadError": "넘겨주기 상태를 불러오지 못했습니다.",
"entryHint": "{name} 님이 이 기록을 직접 관리할 준비가 되었을 때."
"entryHint": "{name} 님이 이 기록을 직접 관리할 준비가 되었을 때.",
"methodSso": "{name}님은 링크를 사용할 때 통합 로그인(SSO)으로 로그인합니다.",
"methodBoth": "{name}님은 비밀번호를 정하거나 통합 로그인(SSO)으로 로그인할 수 있습니다."
}
},
"toast": {
+18 -4
View File
@@ -528,7 +528,7 @@
"loading": "Sprawdzanie linku…",
"invalid": "Ten link już nie działa",
"invalidHint": "Jest nieznany, wygasł, został wycofany albo już użyty. Poproś osobę, która go wysłała, o nowy.",
"oidcOnly": "Ten serwer pozwala tylko na logowanie jednokrotne (SSO), więc profilu nie można tu jeszcze przejąć.",
"oidcOnly": "Ten serwer pozwala tylko na logowanie jednokrotne (SSO). Kontynuuj u swojego dostawcy tożsamości.",
"signedInTitle": "Najpierw się wyloguj",
"signedInBody": "Masz zalogowaną sesję jako {username}. Ten link tworzy logowanie dla kogoś innego, więc wyloguj się, zanim go użyjesz.",
"signOut": "Wyloguj i kontynuuj",
@@ -545,7 +545,19 @@
"errorTaken": "Ta nazwa użytkownika lub adres e-mail jest już zajęty.",
"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."
"errorOffline": "Nie udało się połączyć z serwerem. Sprawdź połączenie i spróbuj ponownie.",
"ssoContinue": "Kontynuuj przez {provider}",
"ssoStarting": "Przekierowywanie…",
"ssoHint": "Od tej pory logujesz się tutaj swoim kontem {provider}.",
"ssoDivider": "lub",
"ssoFallbackName": "logowanie jednokrotne (SSO)",
"errorTitle": "Przejęcie się nie udało",
"errorSubjectBound": "To logowanie należy już do innego konta na tym serwerze. Otwórz link ponownie i użyj innego konta u swojego dostawcy tożsamości.",
"errorEmailTaken": "Twój dostawca tożsamości przekazał adres e-mail, którego używa już inne konto na tym serwerze. Otwórz link ponownie i użyj innego konta albo zapytaj osobę, która administruje serwerem.",
"errorUsernameTaken": "W międzyczasie ktoś zajął tę nazwę użytkownika. Otwórz link ponownie i wybierz inną.",
"errorSignedIn": "Po powrocie byłeś zalogowany. Wyloguj się i otwórz link ponownie.",
"errorSso": "Logowanie u dostawcy tożsamości się nie powiodło. Otwórz link ponownie, aby spróbować jeszcze raz.",
"signInInstead": "Zaloguj się na to konto"
},
"inviteInvalid": {
"title": "Nieprawidłowe zaproszenie",
@@ -11900,9 +11912,11 @@
"revokeFailed": "Nie udało się wycofać linku. Spróbuj ponownie.",
"errorFailed": "Nie udało się utworzyć linku przekazania.",
"errorRosterChanged": "W międzyczasie zmieniło się, kto opiekuje się kartą. Zamknij to i otwórz ponownie.",
"oidcOnly": "Przekazanie profilu nie jest możliwe, dopóki ten serwer pozwala tylko na logowanie jednokrotne (SSO).",
"oidcOnly": "Przekazanie profilu nie jest możliwe na tym serwerze.",
"loadError": "Nie udało się wczytać stanu przekazania.",
"entryHint": "Gdy {name} chce samodzielnie prowadzić tę kartę."
"entryHint": "Gdy {name} chce samodzielnie prowadzić tę kartę.",
"methodSso": "{name} loguje się przez logowanie jednokrotne (SSO), gdy użyje linku.",
"methodBoth": "{name} może ustawić hasło albo zalogować się przez logowanie jednokrotne (SSO)."
}
},
"toast": {
+9 -2
View File
@@ -6,7 +6,9 @@ import { ClaimProfile } from "@/components/auth/claim-profile";
/**
* v1.42 (#959) — `/auth/claim?token=hlp_…`, where the link a Guardian handed
* over (`/claim/<token>`) lands after the edge redirect.
* over (`/claim/<token>`) lands after the edge redirect. Since v1.43 also
* `/auth/claim?error=<reason>`, where a refused single-sign-on claim returns;
* the reason is a fixed word, never a credential.
*
* A URL-credential surface by shape (`session-surface-guard.test.ts`, S2): the
* page reads the one-time token off the query string and passes it to the
@@ -16,5 +18,10 @@ import { ClaimProfile } from "@/components/auth/claim-profile";
*/
export default function ClaimProfilePage() {
const searchParams = useSearchParams();
return <ClaimProfile token={searchParams.get("token")} />;
return (
<ClaimProfile
token={searchParams.get("token")}
error={searchParams.get("error")}
/>
);
}
@@ -19,6 +19,7 @@ vi.mock("@/hooks/use-auth", () => ({
vi.mock("@/lib/queries/use-profile-claim", () => ({
useProfileClaimPreview: () => ({ data: undefined, error: null }),
useClaimProfile: () => ({ mutate: () => {}, isPending: false }),
useClaimProfileSso: () => ({ mutate: () => {}, isPending: false }),
}));
import { ClaimProfile } from "../claim-profile";
@@ -0,0 +1,105 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type { ProfileClaimPreview } from "@/lib/queries/use-profile-claim";
/**
* v1.43 (#959) — the claim page offers the ways in the server names: the
* password form, the identity provider, or both. A refused single-sign-on
* claim comes back with `?error=` and no link, and says what happened.
*/
let previewData: ProfileClaimPreview | undefined;
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: () => {}, refresh: () => {}, push: () => {} }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: null }),
clearCachesForSessionEnd: () => {},
}));
vi.mock("@/lib/queries/use-profile-claim", () => ({
useProfileClaimPreview: () => ({ data: previewData, error: null }),
useClaimProfile: () => ({ mutate: () => {}, isPending: false }),
useClaimProfileSso: () => ({ mutate: () => {}, isPending: false }),
}));
import { ClaimProfile, claimSsoErrorKey } from "../claim-profile";
const BASE: ProfileClaimPreview = {
displayName: "Robin",
expiresAt: "2030-01-01T00:00:00.000Z",
guardians: [],
};
function render(props: { token: string | null; error?: string | null }) {
return renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale="en">
<ClaimProfile {...props} />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("ClaimProfile — ways in", () => {
it("offers only the provider on a single-sign-on-only server", () => {
previewData = { ...BASE, methods: ["sso"], ssoProviderName: "Example ID" };
const html = render({ token: "hlp_x" });
expect(html).toContain('data-slot="claim-sso-continue"');
expect(html).toContain("Continue with Example ID");
expect(html).not.toContain('id="claim-password"');
expect(html).not.toContain('id="claim-email"');
expect(html).not.toContain('data-slot="claim-submit"');
expect(html).toContain('id="claim-username"');
});
it("offers both when both are allowed", () => {
previewData = {
...BASE,
methods: ["password", "sso"],
ssoProviderName: "Example ID",
};
const html = render({ token: "hlp_x" });
expect(html).toContain('data-slot="claim-sso-continue"');
expect(html).toContain('id="claim-password"');
expect(html).toContain('data-slot="claim-submit"');
});
it("offers only the password when an older server names no methods", () => {
previewData = { ...BASE };
const html = render({ token: "hlp_x" });
expect(html).not.toContain('data-slot="claim-sso-continue"');
expect(html).toContain('id="claim-password"');
});
});
describe("ClaimProfile — a refused single-sign-on claim", () => {
it("says what happened and offers a way on", () => {
previewData = undefined;
const html = render({ token: null, error: "claim_sso_subject_bound" });
expect(html).toContain('data-testid="claim-sso-error"');
expect(html).toContain("already belongs to another account");
expect(html).toContain('href="/auth/login"');
});
it("maps every reason the callback sends, and reads an unknown one as a failure", () => {
expect(claimSsoErrorKey("claim_expired")).toBe("auth.claim.invalidHint");
expect(claimSsoErrorKey("claim_sso_subject_bound")).toBe(
"auth.claim.errorSubjectBound",
);
expect(claimSsoErrorKey("claim_email_taken")).toBe(
"auth.claim.errorEmailTaken",
);
expect(claimSsoErrorKey("claim_username_taken")).toBe(
"auth.claim.errorUsernameTaken",
);
expect(claimSsoErrorKey("claim_signed_in")).toBe(
"auth.claim.errorSignedIn",
);
expect(claimSsoErrorKey("oidc_failed")).toBe("auth.claim.errorSso");
expect(claimSsoErrorKey("<script>")).toBe("auth.claim.errorSso");
});
});
+209 -54
View File
@@ -4,9 +4,10 @@ import { useId, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { KeyRound, Loader2, LogOut } from "lucide-react";
import { KeyRound, Loader2, LogIn, LogOut } from "lucide-react";
import { accessLabel } from "@/components/settings/access/managed-profile-handover";
import { useClientTransport } from "@/components/auth/insecure-transport-banner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -15,8 +16,10 @@ import { PasswordStrength } from "@/components/ui/password-strength";
import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth";
import { ApiError, apiFetchRaw } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { loadDocument } from "@/lib/navigation/load-document";
import {
useClaimProfile,
useClaimProfileSso,
useProfileClaimPreview,
} from "@/lib/queries/use-profile-claim";
import { queryKeys } from "@/lib/query-keys";
@@ -33,8 +36,22 @@ import { queryKeys } from "@/lib/query-keys";
* record's name, who keeps which access for now, and until when the link
* holds. No health data. The decision about each Guardian comes right after
* the first sign-in, on the screen the claim lands on.
*
* v1.43 — the ways in follow the preview's `methods`: the password form, a
* "Continue with <provider>" button that claims through single sign-on, or
* both. The single-sign-on leg leaves the page for the identity provider and
* comes back to `/onboarding/handover`, or to this page with `?error=` when
* the callback refused; the link is not in that URL, so the error card asks
* the person to open the link again.
*/
export function ClaimProfile({ token }: { token: string | null }) {
export function ClaimProfile({
token,
error = null,
}: {
token: string | null;
/** The single-sign-on callback's refusal, from `?error=`. */
error?: string | null;
}) {
const router = useRouter();
const queryClient = useQueryClient();
const { t } = useTranslations();
@@ -44,6 +61,8 @@ export function ClaimProfile({ token }: { token: string | null }) {
const { user } = useAuth();
const preview = useProfileClaimPreview(token, !user);
const claim = useClaimProfile();
const claimSso = useClaimProfileSso();
const clientTransport = useClientTransport();
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
@@ -73,6 +92,25 @@ export function ClaimProfile({ token }: { token: string | null }) {
}
}
function continueWithSso() {
if (!token || claimSso.isPending || usernameReserved) return;
claimSso.mutate(
{ token, username },
{
onSuccess: ({ redirectTo }) => {
// Only the path and query of the server's answer, so a response
// could never send the browser off this origin.
const next = new URL(redirectTo, window.location.origin);
if (clientTransport) {
next.searchParams.set("client_proto", clientTransport.protocol);
next.searchParams.set("client_host", clientTransport.host);
}
loadDocument(`${next.pathname}${next.search}`);
},
},
);
}
function submit(event: React.FormEvent) {
event.preventDefault();
if (!token || claim.isPending || usernameReserved) return;
@@ -118,6 +156,35 @@ export function ClaimProfile({ token }: { token: string | null }) {
);
}
if (!token && error) {
const bound = error === "claim_sso_subject_bound";
return (
<ClaimCard testId="claim-sso-error">
<Header
title={
error === "claim_expired"
? t("auth.claim.invalid")
: t("auth.claim.errorTitle")
}
description={t(claimSsoErrorKey(error))}
/>
{/* The way on: the sign-in page (for a sign-in that already belongs
to an account, that account's). The link itself is not here. */}
<Button
asChild
variant="outline"
size="lg"
className="mt-8 min-h-11 w-full"
data-slot="claim-to-login"
>
<Link href="/auth/login">
{bound ? t("auth.claim.signInInstead") : t("auth.login")}
</Link>
</Button>
</ClaimCard>
);
}
if (!token || previewStatus === 404 || previewStatus === 422) {
return (
<ClaimCard testId="claim-invalid">
@@ -192,12 +259,18 @@ export function ClaimProfile({ token }: { token: string | null }) {
}
const data = preview.data;
const errorKey = claim.isError ? claimErrorKey(claim.error) : null;
// An older server sends no `methods`: it only ever offered the password.
const methods = data.methods ?? ["password"];
const offersPassword = methods.includes("password");
const offersSso = methods.includes("sso");
const providerName = data.ssoProviderName ?? t("auth.claim.ssoFallbackName");
const failed = claimSso.isError ? claimSso : claim;
const errorKey = failed.isError ? claimErrorKey(failed.error) : null;
const errorText =
claim.error instanceof ApiError &&
claim.error.status === 422 &&
claim.error.message
? claim.error.message
failed.error instanceof ApiError &&
failed.error.status === 422 &&
failed.error.message
? failed.error.message
: errorKey
? t(errorKey)
: null;
@@ -241,7 +314,19 @@ export function ClaimProfile({ token }: { token: string | null }) {
</p>
</section>
<form onSubmit={submit} className="mt-6 space-y-4" data-slot="claim-form">
<form
onSubmit={
offersPassword
? submit
: (event) => {
// Enter in the username field on an SSO-only server.
event.preventDefault();
continueWithSso();
}
}
className="mt-6 space-y-4"
data-slot="claim-form"
>
<div className="space-y-2">
<Label htmlFor="claim-username">{t("auth.username")}</Label>
<Input
@@ -270,36 +355,43 @@ export function ClaimProfile({ token }: { token: string | null }) {
: t("auth.claim.usernameHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="claim-email">{t("auth.email")}</Label>
<Input
id="claim-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
autoCapitalize="none"
spellCheck={false}
aria-describedby={`${errorId}-email`}
/>
<p id={`${errorId}-email`} className="text-muted-foreground text-xs">
{t("auth.claim.emailHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="claim-password">{t("auth.password")}</Label>
<Input
id="claim-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="new-password"
placeholder={t("auth.passwordMinLength")}
/>
<PasswordStrength password={password} />
</div>
{offersPassword && (
<>
<div className="space-y-2">
<Label htmlFor="claim-email">{t("auth.email")}</Label>
<Input
id="claim-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
autoCapitalize="none"
spellCheck={false}
aria-describedby={`${errorId}-email`}
/>
<p
id={`${errorId}-email`}
className="text-muted-foreground text-xs"
>
{t("auth.claim.emailHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="claim-password">{t("auth.password")}</Label>
<Input
id="claim-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="new-password"
placeholder={t("auth.passwordMinLength")}
/>
<PasswordStrength password={password} />
</div>
</>
)}
<p className="text-sm">{t("auth.claim.consentNote")}</p>
<p className="text-muted-foreground text-xs">
@@ -320,22 +412,63 @@ export function ClaimProfile({ token }: { token: string | null }) {
</div>
)}
<Button
type="submit"
size="lg"
className="min-h-11 w-full"
disabled={claim.isPending || usernameReserved}
data-slot="claim-submit"
>
{claim.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : (
<KeyRound className="size-4" />
)}
{claim.isPending
? t("auth.claim.submitting")
: t("auth.claim.submit")}
</Button>
{offersPassword && (
<Button
type="submit"
size="lg"
className="min-h-11 w-full"
disabled={claim.isPending || usernameReserved}
data-slot="claim-submit"
>
{claim.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : (
<KeyRound className="size-4" />
)}
{claim.isPending
? t("auth.claim.submitting")
: t("auth.claim.submit")}
</Button>
)}
{offersPassword && offersSso && (
<div
className="text-muted-foreground flex items-center gap-3 text-xs"
aria-hidden="true"
>
<span className="bg-border h-px flex-1" />
{t("auth.claim.ssoDivider")}
<span className="bg-border h-px flex-1" />
</div>
)}
{offersSso && (
<div className="space-y-2" data-slot="claim-sso">
<Button
type="button"
size="lg"
variant={offersPassword ? "outline" : "default"}
className="min-h-11 w-full"
disabled={
claimSso.isPending || usernameReserved || username.length < 3
}
onClick={continueWithSso}
data-slot="claim-sso-continue"
>
{claimSso.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : (
<LogIn className="size-4" />
)}
{claimSso.isPending
? t("auth.claim.ssoStarting")
: t("auth.claim.ssoContinue", { provider: providerName })}
</Button>
<p className="text-muted-foreground text-xs">
{t("auth.claim.ssoHint", { provider: providerName })}
</p>
</div>
)}
</form>
</ClaimCard>
);
@@ -378,6 +511,28 @@ function Header({
);
}
/**
* v1.43 — the message for the single-sign-on callback's refusal, from the
* claim page's `?error=`. Exported and pure so it can be pinned. A reason this
* build does not know reads as the generic failure.
*/
export function claimSsoErrorKey(reason: string): string {
switch (reason) {
case "claim_expired":
return "auth.claim.invalidHint";
case "claim_sso_subject_bound":
return "auth.claim.errorSubjectBound";
case "claim_email_taken":
return "auth.claim.errorEmailTaken";
case "claim_username_taken":
return "auth.claim.errorUsernameTaken";
case "claim_signed_in":
return "auth.claim.errorSignedIn";
default:
return "auth.claim.errorSso";
}
}
/** The message for a refused claim. Exported and pure so it can be pinned. */
export function claimErrorKey(err: unknown): string {
if (!(err instanceof ApiError)) return "auth.claim.errorOffline";
@@ -110,6 +110,18 @@ export function ManagedProfileHandover({
name: profileName,
})}
</p>
{/* v1.43 — how the person will get in, when it is not only a password
they set themselves (the description already implies that one). */}
{status.data.available && status.data.methods?.includes("sso") ? (
<p className="text-sm" data-slot="managed-profile-handover-methods">
{t(
status.data.methods.includes("password")
? "recordSharing.managed.handover.methodBoth"
: "recordSharing.managed.handover.methodSso",
{ name: profileName },
)}
</p>
) : null}
{!status.data.available ? (
<>
+7 -1
View File
@@ -20,6 +20,7 @@
* invalidation path (`grantDependentKeys`).
*/
import type { HandoverMethod } from "@/lib/validations/claim-sso";
import {
useMutation,
useQuery,
@@ -346,8 +347,13 @@ export function useRemoveManagedProfileGuardian() {
/** What `GET /api/managed-profiles/{id}/handover` answers. */
export interface ManagedProfileHandoverStatus {
/** False on a single-sign-on-only instance, where no link can be made. */
/** False only when no way to redeem a link exists on this instance. */
available: boolean;
/**
* v1.43 — how the person can redeem the link here. Absent from an older
* server, which only offered the password.
*/
methods?: HandoverMethod[];
open: {
id: string;
createdAt: string;