mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(auth): an invitation never registers over a live session
`POST /api/auth/register` read no session at all, so a request carrying a valid `healthlog_session` was handled exactly like an anonymous one and the unconditional `createSession` at the end replaced the caller's cookie with the new account's. An admin opening his own invite link to check it was signed out of his admin account and into a stranger's onboarding, his previous session row still valid server-side and no longer reachable. The route now resolves the optional session first and refuses with 409 `already_authenticated`, before the rate-limit bucket is spent and before the invitation is consumed, so a signed-in visitor neither burns a use nor locks the door for the person the invitation is for. The register page renders an explaining panel with a sign-out button in place of the form, so nobody has to fill three inputs to discover the refusal; signing out returns to the same invitation rather than to the login screen. Copy lands in all seven locales.
This commit is contained in:
1 parent
cfca84a294
commit
ce3c844a58
11 files changed
+373
-4
No files matched your search
@@ -413,6 +413,11 @@
|
||||
"loginFailed": "Anmeldung fehlgeschlagen",
|
||||
"registerFailed": "Registrierung fehlgeschlagen",
|
||||
"invitedBanner": "Du wurdest eingeladen — erstelle dein Konto, auch wenn die Registrierung sonst geschlossen ist.",
|
||||
"alreadySignedIn": {
|
||||
"title": "Du bist bereits angemeldet",
|
||||
"body": "Du bist als {username} angemeldet. Eine Einladung legt ein neues Konto an. Melde dich ab, um sie anzunehmen.",
|
||||
"signOut": "Abmelden und Einladung annehmen"
|
||||
},
|
||||
"createAccount": "Konto erstellen",
|
||||
"newAccount": "Neues Konto erstellen",
|
||||
"backToPasskey": "Zurück zu Passkey",
|
||||
|
||||
@@ -413,6 +413,11 @@
|
||||
"loginFailed": "Login failed",
|
||||
"registerFailed": "Registration failed",
|
||||
"invitedBanner": "You have been invited — create your account even while registration is otherwise closed.",
|
||||
"alreadySignedIn": {
|
||||
"title": "You are already signed in",
|
||||
"body": "You are signed in as {username}. An invitation creates a new account — sign out to accept it.",
|
||||
"signOut": "Sign out and accept the invitation"
|
||||
},
|
||||
"createAccount": "Create account",
|
||||
"newAccount": "Create new account",
|
||||
"backToPasskey": "Back to Passkey",
|
||||
|
||||
@@ -413,6 +413,11 @@
|
||||
"loginFailed": "No se ha podido iniciar sesión",
|
||||
"registerFailed": "No se ha podido completar el registro",
|
||||
"invitedBanner": "Te han invitado: crea tu cuenta aunque el registro esté cerrado.",
|
||||
"alreadySignedIn": {
|
||||
"title": "Ya has iniciado sesión",
|
||||
"body": "Has iniciado sesión como {username}. Una invitación crea una cuenta nueva. Cierra la sesión para aceptarla.",
|
||||
"signOut": "Cerrar sesión y aceptar la invitación"
|
||||
},
|
||||
"createAccount": "Crear cuenta",
|
||||
"newAccount": "Crear cuenta nueva",
|
||||
"backToPasskey": "Volver a passkey",
|
||||
|
||||
@@ -413,6 +413,11 @@
|
||||
"loginFailed": "Échec de la connexion",
|
||||
"registerFailed": "Échec de l’inscription",
|
||||
"invitedBanner": "Vous avez été invité — créez votre compte même si les inscriptions sont fermées.",
|
||||
"alreadySignedIn": {
|
||||
"title": "Tu es déjà connecté",
|
||||
"body": "Tu es connecté en tant que {username}. Une invitation crée un nouveau compte. Déconnecte-toi pour l'accepter.",
|
||||
"signOut": "Se déconnecter et accepter l'invitation"
|
||||
},
|
||||
"createAccount": "Créer un compte",
|
||||
"newAccount": "Créer un nouveau compte",
|
||||
"backToPasskey": "Revenir à la passkey",
|
||||
|
||||
@@ -413,6 +413,11 @@
|
||||
"loginFailed": "Accesso non riuscito",
|
||||
"registerFailed": "Registrazione non riuscita",
|
||||
"invitedBanner": "Sei stato invitato — crea il tuo account anche se la registrazione è chiusa.",
|
||||
"alreadySignedIn": {
|
||||
"title": "Hai già effettuato l'accesso",
|
||||
"body": "Hai effettuato l'accesso come {username}. Un invito crea un nuovo account. Esci per accettarlo.",
|
||||
"signOut": "Esci e accetta l'invito"
|
||||
},
|
||||
"createAccount": "Crea account",
|
||||
"newAccount": "Crea un nuovo account",
|
||||
"backToPasskey": "Torna a passkey",
|
||||
|
||||
@@ -413,6 +413,11 @@
|
||||
"loginFailed": "로그인 실패",
|
||||
"registerFailed": "회원가입 실패",
|
||||
"invitedBanner": "초대를 받았어요 — 회원가입이 닫혀 있어도 계정을 만들 수 있어요.",
|
||||
"alreadySignedIn": {
|
||||
"title": "이미 로그인되어 있습니다",
|
||||
"body": "{username} 계정으로 로그인되어 있습니다. 초대는 새 계정을 만듭니다. 초대를 수락하려면 로그아웃하세요.",
|
||||
"signOut": "로그아웃하고 초대 수락하기"
|
||||
},
|
||||
"createAccount": "계정 만들기",
|
||||
"newAccount": "새 계정 만들기",
|
||||
"backToPasskey": "패스키로 돌아가기",
|
||||
|
||||
@@ -413,6 +413,11 @@
|
||||
"loginFailed": "Logowanie nie powiodło się",
|
||||
"registerFailed": "Rejestracja nie powiodła się",
|
||||
"invitedBanner": "Otrzymano zaproszenie — utwórz konto, nawet jeśli rejestracja jest zamknięta.",
|
||||
"alreadySignedIn": {
|
||||
"title": "Masz już zalogowaną sesję",
|
||||
"body": "Masz zalogowaną sesję jako {username}. Zaproszenie tworzy nowe konto. Wyloguj się, aby je przyjąć.",
|
||||
"signOut": "Wyloguj się i przyjmij zaproszenie"
|
||||
},
|
||||
"createAccount": "Utwórz konto",
|
||||
"newAccount": "Utwórz nowe konto",
|
||||
"backToPasskey": "Wróć do passkey",
|
||||
|
||||
@@ -31,8 +31,19 @@ vi.mock("@/lib/auth/login-alert", () => ({
|
||||
recordSignInDevice: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// The invite ledger. A refused registration must never reach it — the
|
||||
// assertion below is that `uses` is left alone, and the only way this route
|
||||
// can touch it is through `consumeInviteToken`.
|
||||
vi.mock("@/lib/auth/invite-token", () => ({
|
||||
consumeInviteToken: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, inviteId: "inv-1" }),
|
||||
recordInviteConsumer: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
createSession: vi.fn().mockResolvedValue(undefined),
|
||||
getSession: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/password", () => ({
|
||||
@@ -78,7 +89,8 @@ import { POST } from "../route";
|
||||
import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkPasswordStrength, hashPassword } from "@/lib/auth/password";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { createSession, getSession } from "@/lib/auth/session";
|
||||
import { consumeInviteToken } from "@/lib/auth/invite-token";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
@@ -96,6 +108,7 @@ beforeEach(() => {
|
||||
allowed: true,
|
||||
ip: "1.2.3.4",
|
||||
} as never);
|
||||
vi.mocked(getSession).mockResolvedValue(null as never);
|
||||
});
|
||||
|
||||
describe("POST /api/auth/register — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
@@ -267,3 +280,97 @@ describe("POST /api/auth/register — OIDC_ONLY server-side enforcement", () =>
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.38.19 — an invitation creates a NEW account, so it cannot be accepted
|
||||
* from inside somebody's live session.
|
||||
*
|
||||
* Until this gate the route read no session at all: a request carrying a valid
|
||||
* `healthlog_session` was treated exactly like an anonymous one, and the
|
||||
* unconditional `createSession` at the end overwrote the caller's cookie with
|
||||
* the fresh account's. The admin who clicked his own invite link to check it
|
||||
* was signed out of his admin account and into a stranger's onboarding, with
|
||||
* his previous session row left valid server-side and unreachable.
|
||||
*/
|
||||
describe("POST /api/auth/register — refuses a live session (v1.38.19)", () => {
|
||||
const invitedBody = {
|
||||
email: "new@example.com",
|
||||
username: "newuser",
|
||||
password: "a-very-strong-password-123",
|
||||
inviteToken: `hlv_${"a".repeat(64)}`,
|
||||
};
|
||||
|
||||
// Everything the happy path needs is stubbed, so the ONLY reason this
|
||||
// request can fail is the gate under test. Without it the same request
|
||||
// answers 201 and mints a session over the caller's cookie.
|
||||
beforeEach(() => {
|
||||
vi.mocked(consumeInviteToken).mockResolvedValue({
|
||||
ok: true,
|
||||
inviteId: "inv-1",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(1 as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
|
||||
vi.mocked(checkPasswordStrength).mockReturnValue({
|
||||
isAcceptable: true,
|
||||
feedback: [],
|
||||
} as never);
|
||||
vi.mocked(hashPassword).mockResolvedValue("hashed" as never);
|
||||
vi.mocked(prisma.$transaction).mockImplementation(((
|
||||
fn: (tx: typeof prisma) => unknown,
|
||||
) => fn(prisma)) as never);
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ locked: 1 }] as never);
|
||||
vi.mocked(prisma.user.create).mockResolvedValue({
|
||||
id: "u1",
|
||||
username: "newuser",
|
||||
email: "new@example.com",
|
||||
role: "USER",
|
||||
} as never);
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 1e6) },
|
||||
user: { id: "u-existing", username: "admin", email: "a@example.com" },
|
||||
} as never);
|
||||
});
|
||||
|
||||
it("answers 409 already_authenticated", async () => {
|
||||
const res = await postPathThrough(invitedBody);
|
||||
expect(res.status).toBe(409);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.meta?.errorCode).toBe("already_authenticated");
|
||||
expect(body.error).toMatch(/sign/i);
|
||||
});
|
||||
|
||||
it("creates no account", async () => {
|
||||
await postPathThrough(invitedBody);
|
||||
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not burn a use of the invitation", async () => {
|
||||
await postPathThrough(invitedBody);
|
||||
expect(consumeInviteToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not touch the caller's own session", async () => {
|
||||
await postPathThrough(invitedBody);
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses before the rate-limit bucket is spent", async () => {
|
||||
// The gate is a session read, not an attempt: a signed-in visitor
|
||||
// clicking the link three times must not lock the door for the person
|
||||
// the invitation is actually for, who shares the IP on a home network.
|
||||
await postPathThrough(invitedBody);
|
||||
expect(checkAuthSurfaceRateLimit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still registers an anonymous caller", async () => {
|
||||
// The control. Same request, no session — the invitation works.
|
||||
vi.mocked(getSession).mockResolvedValue(null as never);
|
||||
const res = await postPathThrough(invitedBody);
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "@/lib/auth/invite-token";
|
||||
import { registerSchema } from "@/lib/validations/auth";
|
||||
import { hashPassword, checkPasswordStrength } from "@/lib/auth/password";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { createSession, getSession } from "@/lib/auth/session";
|
||||
import { recordSignInDevice } from "@/lib/auth/login-alert";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -39,6 +39,31 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
// v1.38.19 — an invitation creates a NEW account, so it can never be
|
||||
// accepted from inside a live session.
|
||||
//
|
||||
// The route used to read no session at all: a request carrying a valid
|
||||
// `healthlog_session` was handled exactly like an anonymous one, and the
|
||||
// unconditional `createSession` at the end overwrote the caller's cookie
|
||||
// with the fresh account's. An admin opening his own invite link to check it
|
||||
// was signed out of his admin account and into a stranger's onboarding, his
|
||||
// previous session row still valid server-side and no longer reachable.
|
||||
//
|
||||
// Refused BEFORE the rate limit on purpose: this is a state, not an attempt,
|
||||
// and spending the 5-per-15-minutes bucket on it would lock the door for the
|
||||
// person the invitation is actually for, who may well share the IP.
|
||||
// `getSession` is the optional resolver — it answers `null` for an anonymous
|
||||
// caller instead of throwing, which `requireAuth` would.
|
||||
const existingSession = await getSession();
|
||||
if (existingSession) {
|
||||
annotate({ action: { name: "auth.register.refused_authenticated" } });
|
||||
return apiError(
|
||||
"Already signed in — sign out first to use an invitation",
|
||||
409,
|
||||
{ errorCode: "already_authenticated" },
|
||||
);
|
||||
}
|
||||
|
||||
// v1.4.43 W13 M-4 — tighten to a global bucket when the trust chain
|
||||
// is misconfigured; otherwise byte-equivalent per-IP semantics.
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* An invitation opened from inside a live session.
|
||||
*
|
||||
* `POST /api/auth/register` refuses that with 409 `already_authenticated`
|
||||
* (see `src/app/api/auth/register/__tests__/route.test.ts`). The page must not
|
||||
* make the visitor discover it by filling the form: a signed-in caller gets an
|
||||
* explanation and a way out instead of three inputs that cannot succeed.
|
||||
*
|
||||
* SSR-only, per project convention (node environment, no DOM, no
|
||||
* `@testing-library/react`), so what is asserted is the PAINT — which branch
|
||||
* the page rendered. The sign-out click itself is driven in
|
||||
* `e2e/invite-registration.spec.ts`.
|
||||
*
|
||||
* Mutation check, run: drop the `isAuthenticated` branch → the two signed-in
|
||||
* cases go red naming the form inputs they found, the anonymous case stays
|
||||
* green.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
const authRef = {
|
||||
value: {
|
||||
user: null as { username: string } | null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
},
|
||||
};
|
||||
const inviteRef = { value: null as string | null };
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
|
||||
useSearchParams: () => ({
|
||||
get: (key: string) => (key === "invite" ? inviteRef.value : null),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => authRef.value,
|
||||
clearCachesForSessionEnd: vi.fn(),
|
||||
}));
|
||||
|
||||
import RegisterPage from "../page";
|
||||
|
||||
const TOKEN = `hlv_${"a".repeat(64)}`;
|
||||
|
||||
function render(): string {
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<RegisterPage />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function anonymous(invite: string | null): string {
|
||||
authRef.value = { user: null, isAuthenticated: false, isLoading: false };
|
||||
inviteRef.value = invite;
|
||||
return render();
|
||||
}
|
||||
|
||||
function signedIn(invite: string | null, username = "marc"): string {
|
||||
authRef.value = {
|
||||
user: { username },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
};
|
||||
inviteRef.value = invite;
|
||||
return render();
|
||||
}
|
||||
|
||||
describe("register page — an invitation cannot be accepted from a live session", () => {
|
||||
it("renders the form for an anonymous visitor holding an invitation", () => {
|
||||
const markup = anonymous(TOKEN);
|
||||
expect(markup).toContain('data-testid="register-invite-banner"');
|
||||
expect(markup).toContain('id="username"');
|
||||
expect(markup).not.toContain('data-testid="register-already-signed-in"');
|
||||
});
|
||||
|
||||
it("replaces the form with the explaining panel for a signed-in visitor", () => {
|
||||
const markup = signedIn(TOKEN);
|
||||
expect(markup).toContain('data-testid="register-already-signed-in"');
|
||||
expect(markup).not.toContain('id="username"');
|
||||
expect(markup).not.toContain('id="password"');
|
||||
});
|
||||
|
||||
it("names the account the visitor is signed in as", () => {
|
||||
const markup = signedIn(TOKEN, "grandma");
|
||||
expect(markup).toContain("grandma");
|
||||
});
|
||||
|
||||
it("offers the way out", () => {
|
||||
const markup = signedIn(TOKEN);
|
||||
expect(markup).toContain('data-testid="register-sign-out"');
|
||||
});
|
||||
|
||||
it("explains the same thing without an invitation in the URL", () => {
|
||||
// Reaching /auth/register signed in is the same dead end with or without
|
||||
// a token — the form below could only ever answer 409.
|
||||
const markup = signedIn(null);
|
||||
expect(markup).toContain('data-testid="register-already-signed-in"');
|
||||
});
|
||||
|
||||
it("holds the panel back while the session question is unanswered", () => {
|
||||
// Fail closed the other way round: an unresolved `/api/auth/me` must not
|
||||
// flash "you are already signed in" at somebody who is not.
|
||||
authRef.value = { user: null, isAuthenticated: false, isLoading: true };
|
||||
inviteRef.value = TOKEN;
|
||||
const markup = render();
|
||||
expect(markup).not.toContain('data-testid="register-already-signed-in"');
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useId, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, MailOpen, UserPlus } from "lucide-react";
|
||||
import { Loader2, LogOut, MailOpen, UserPlus } from "lucide-react";
|
||||
import { Logo } from "@/components/ui/logo";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -13,7 +13,8 @@ import { PasswordStrength } from "@/components/ui/password-strength";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { detectBrowserTimezone } from "@/lib/tz/format";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { ApiError, apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
|
||||
import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
@@ -23,6 +24,15 @@ export default function RegisterPage() {
|
||||
// admits the invited user; an open instance simply ignores it.
|
||||
const inviteToken = searchParams.get("invite");
|
||||
const queryClient = useQueryClient();
|
||||
// v1.38.19 — an invitation creates a NEW account, so it cannot be accepted
|
||||
// from inside a live session; `POST /api/auth/register` refuses one with
|
||||
// 409 `already_authenticated`. Rendering the form anyway would let the
|
||||
// visitor discover that by filling it in. `isLoading` holds the panel back
|
||||
// until the session question is actually answered — failing closed the
|
||||
// other way round would flash "you are already signed in" at somebody who
|
||||
// is not.
|
||||
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const [signingOut, setSigningOut] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -69,6 +79,83 @@ export default function RegisterPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSignOut() {
|
||||
setError(null);
|
||||
setSigningOut(true);
|
||||
try {
|
||||
await apiFetchRaw("/api/auth/logout", { method: "POST" });
|
||||
// Same session-end wipe the shell and the menu do: the QueryClient
|
||||
// outlives the navigation on this SPA and the health-data families are
|
||||
// not user-scoped, so nothing may survive into the next account.
|
||||
clearCachesForSessionEnd(queryClient);
|
||||
// Back to this page, invitation intact — NOT to the login screen the
|
||||
// generic logout goes to. The point of the trip is to accept the
|
||||
// invitation, and the token only rides the URL.
|
||||
router.replace(
|
||||
inviteToken
|
||||
? `/auth/register?invite=${encodeURIComponent(inviteToken)}`
|
||||
: "/auth/register",
|
||||
);
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError(t("common.networkError"));
|
||||
} finally {
|
||||
setSigningOut(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isAuthenticated && !authLoading) {
|
||||
return (
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="border-border bg-card rounded-xl border p-6 shadow-lg shadow-black/20 sm:p-8">
|
||||
<div
|
||||
className="flex flex-col items-center gap-3 text-center"
|
||||
data-testid="register-already-signed-in"
|
||||
>
|
||||
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<Logo className="text-primary" size={28} />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold tracking-tight">
|
||||
{t("auth.alreadySignedIn.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("auth.alreadySignedIn.body", {
|
||||
username: user?.username ?? "",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="bg-destructive/10 text-destructive mt-6 rounded-lg p-3 text-sm"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
className="mt-8 min-h-11 w-full"
|
||||
onClick={handleSignOut}
|
||||
disabled={signingOut}
|
||||
aria-busy={signingOut || undefined}
|
||||
data-testid="register-sign-out"
|
||||
>
|
||||
{signingOut ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<LogOut className="h-4 w-4" />
|
||||
)}
|
||||
{t("auth.alreadySignedIn.signOut")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-sm">
|
||||
{/* v1.4.27 MB7 / CF-61 — drop `p-8` to `p-6 sm:p-8` so the
|
||||
|
||||
Reference in new issue
Block a user