mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
3fd6bd6081
commit
0ebb61343e
13 files changed
+469
-85
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user