Ctrl K
ComunidadGuías…

src/components/auth/verify-page-content.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"use client";

import { Suspense, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { authAPI } from "@/lib/api/auth";
import { useAuth } from "@/contexts/AuthContext";
import { useLinkToken } from "@/hooks/useLinkToken";
import { Button } from "@/components/ui/button";
import { StandaloneShell } from "@/components/layout/standalone-shell";
import {
  StandaloneCard,
  StandaloneCardHeader,
} from "@/components/layout/standalone-card";
import { getApiErrorMessage } from "@/lib/api/error";
import {
  consumePostAuthRedirect,
  destinationForOutcome,
} from "@/lib/auth-redirect";
import { formatPurgeDay, parsePurgeDate } from "@/lib/purge-date";
import {
  AlertCircle,
  CalendarClock,
  Loader2,
  LogIn,
  MailCheck,
  RotateCcw,
} from "lucide-react";

// The magic-link landing page - this is what `{FRONTEND_URL}/auth/verify?token=...`
// (see the backend's `services.email_service`) actually points to. Mirrors the
// sign-up flow's own protection: completing a new account requires a real user to
// submit the profile-completion form (`/onboarding`), which naturally can't be done
// by automation - so this page requires an explicit "Sign in" click before the
// `POST /auth/email/verify` call that actually signs the caller in ever fires. A
// mail client's link-preview/security-scanning feature (which may render this page
// in a real, JS-executing browser) can load it, but it can't fake a real click, so
// the token isn't touched until an actual person acts.
//
// Before showing that button at all, a side-effect-free `GET .../verify/check` call
// determines whether the link is even still live - this is what actually keeps a
// revisit (e.g. via the browser's back button after already signing in) from
// showing a misleadingly clickable button. The backend's idempotent handling of an
// already-used-but-not-superseded token (see the API's `DECISIONS.md`) is still in
// place as defense-in-depth for races, but isn't relied on as the primary defense.
//
// That same precheck is what makes this the one entry point of four whose button can
// tell the truth about a deleted account *before* anything is spent: it answers
// `deletion_pending` alongside the date, and the button then reads "Restore my
// account". The code, Google and passkey paths have no such look-before-you-click
// step, so they show the offer on `/restore` after their POST returns.
export function VerifyMagicLinkPageContent() {
  return (
    <Suspense fallback={<VerifyStatus state="checking" />}>
      <VerifyMagicLinkContent />
    </Suspense>
  );
}

type VerifyState =
  "checking" | "ready" | "restore" | "verifying" | "restoring" | "error";

const LINK_FAILED = "This sign-in link is invalid or has expired.";
const RESTORE_FAILED = "Couldn't restore your account. Please try again.";

function VerifyMagicLinkContent() {
  const router = useRouter();
  const { verifyEmailLink, restoreAccount } = useAuth();
  const token = useLinkToken();
  const [state, setState] = useState<VerifyState>(token ? "checking" : "error");
  const [error, setError] = useState<string | null>(
    token ? null : "This sign-in link is missing its token.",
  );
  const [email, setEmail] = useState<string | null>(null);
  // Set by the precheck for a link into an account inside its grace period, and
  // never cleared afterwards: it decides what the button does, and then what the
  // error card says if that fails. Null `purgeAfter` is the API's answer for a row
  // flagged with no clock to count from - the offer stands without a date on it.
  const [pendingDeletion, setPendingDeletion] = useState<{
    purgeAfter: string | null;
  } | null>(null);
  const checkedRef = useRef(false);

  useEffect(() => {
    if (!token || checkedRef.current) return;
    checkedRef.current = true;

    authAPI
      .checkEmailLink(token)
      .then((result) => {
        if (!result.valid) {
          setError(
            "This sign-in link has already been used, or is invalid or expired.",
          );
          setState("error");
          return;
        }
        setEmail(result.email ?? null);
        // `deletion_pending` rides on `valid: true` - the link works, it just
        // leads somewhere else - which is why this branch sits after the one
        // above rather than in front of it.
        if (result.deletion_pending) {
          setPendingDeletion({ purgeAfter: result.purge_after ?? null });
          setState("restore");
          return;
        }
        setState("ready");
      })
      .catch((err) => {
        setError(getApiErrorMessage(err, LINK_FAILED));
        setState("error");
      });
  }, [token]);

  const handleConfirm = async () => {
    if (!token) return;

    // Read before the state changes below, and what tells the two clicks apart for
    // the rest of this handler.
    const restoring = state === "restore";
    setState(restoring ? "restoring" : "verifying");
    try {
      const outcome = await verifyEmailLink(token);
      // Consumed unconditionally, even when heading to onboarding: a brand-new
      // account has nothing to return to, and leaving the value behind would
      // only let it surface at some unrelated later sign-in in this tab.
      const next = consumePostAuthRedirect();

      // The button said "Restore my account" and it was clicked, so the decision
      // this feature exists to ask for has been made - chaining straight into the
      // second half is that decision being carried out, not a step being skipped.
      // The `status` check is not a formality: a deletion requested between the
      // precheck and the click arrives here too, and a click on a button that said
      // "Sign in" must never silently restore anything. That case falls through to
      // `/restore`, where the offer is made properly.
      if (restoring && outcome.status === "deletion_pending") {
        await restoreAccount(outcome.restore_token);
        router.replace(destinationForOutcome("authenticated", next));
        return;
      }
      router.replace(destinationForOutcome(outcome.status, next));
    } catch (err) {
      setError(
        getApiErrorMessage(err, restoring ? RESTORE_FAILED : LINK_FAILED),
      );
      setState("error");
    }
  };

  return (
    <VerifyStatus
      state={state}
      error={error}
      email={email}
      pendingDeletion={pendingDeletion}
      onConfirm={handleConfirm}
    />
  );
}

function VerifyStatus({
  state,
  error,
  email,
  pendingDeletion,
  onConfirm,
}: {
  state: VerifyState;
  error?: string | null;
  email?: string | null;
  pendingDeletion?: { purgeAfter: string | null } | null;
  onConfirm?: () => void;
}) {
  const purgeOn = parsePurgeDate(pendingDeletion?.purgeAfter);

  return (
    <StandaloneShell className="text-center">
      <StandaloneCard>
        {state === "checking" && (
          <StandaloneCardHeader
            icon={Loader2}
            iconClassName="animate-spin"
            description="Checking your sign-in link..."
          />
        )}

        {state === "ready" && (
          <>
            <StandaloneCardHeader
              icon={MailCheck}
              title="Ready to sign you in"
              description={
                email ? (
                  <>
                    Sign in below as{" "}
                    <span className="font-medium text-foreground">{email}</span>
                    .
                  </>
                ) : (
                  "Sign in below to continue to OpenDiving."
                )
              }
            />
            <Button onClick={onConfirm}>
              <LogIn className="h-4 w-4 mr-2" />
              Sign in
            </Button>
          </>
        )}

        {state === "restore" && (
          <>
            <StandaloneCardHeader
              icon={CalendarClock}
              iconClassName="text-muted-foreground"
              title="This account is scheduled for deletion"
              description={
                <>
                  {email ? (
                    <>
                      <span className="font-medium text-foreground">
                        {email}
                      </span>{" "}
                      was deleted, and nothing has been erased yet.
                    </>
                  ) : (
                    "This account was deleted, and nothing has been erased yet."
                  )}{" "}
                  {purgeOn ? (
                    <>
                      Restoring signs you in and brings back your dives, dive
                      drafts, dive sites, trips, courses, certifications, gear,
                      contacts, people and tags. After{" "}
                      <span className="font-medium text-foreground">
                        {formatPurgeDay(purgeOn)}
                      </span>{" "}
                      nothing can be restored.
                    </>
                  ) : (
                    "Restoring signs you in and brings back your dives, dive drafts, dive sites, trips, courses, certifications, gear, contacts, people and tags. Once the erasure date passes, nothing can be restored."
                  )}
                </>
              }
            />
            <Button onClick={onConfirm}>
              <RotateCcw className="h-4 w-4 mr-2" />
              Restore my account
            </Button>
          </>
        )}

        {state === "verifying" && (
          <StandaloneCardHeader
            icon={Loader2}
            iconClassName="animate-spin"
            description="Signing you in..."
          />
        )}

        {state === "restoring" && (
          <StandaloneCardHeader
            icon={Loader2}
            iconClassName="animate-spin"
            description="Restoring your account..."
          />
        )}

        {state === "error" && (
          <>
            <StandaloneCardHeader
              icon={AlertCircle}
              iconClassName="text-destructive"
              title={
                pendingDeletion
                  ? "We couldn't restore your account"
                  : "We couldn't sign you in"
              }
              description={error}
            />
            <p className="text-sm text-muted-foreground">
              <Link href="/signin" className="underline hover:text-foreground">
                Request a new sign-in link
              </Link>
            </p>
          </>
        )}
      </StandaloneCard>
    </StandaloneShell>
  );
}