feat: verify email addresses and recover forgotten passwords
Ekklesia could authenticate a person and, since Pass 1, send a transactional email — but it had no way to establish that an address belonged to whoever claimed it, and no way back in for someone who forgot their password. A verified address is now a precondition for holding a session. Better Auth enforces that after the password check and before any session exists, so an unverified sign-in returns 403 with no cookie. That is why no "unverified principal" was needed: a correct password on an unverified address carries exactly the authority of no password, and /api/v1/principal is unchanged. Better Auth keeps every token. Ekklesia adds no token table, no hashing, and no expiry clock. It supplies a recipient, a subject, and two bodies, and the mail provider supplies everything about SMTP. Verification and reset links are rebuilt from a validated Dashboard origin, taking only the token — never the URL Better Auth prebuilds from a caller-supplied callbackURL — so no request can steer where a recipient lands. Following a verification link mints no session, because mail infrastructure dereferences links. Reset revokes that person's other sessions and leaves managed devices alone. Recovery answers identically for an address with an account, without one, already verified, or undeliverable — including when SMTP is broken, which is why the delivery adapter never throws. The initial platform owner is the one exception: created already verified by an operator command with no mail prerequisite, so a fresh server can be signed in to before a relay exists. Migration 0007 extends that to owners of installations that already exist. Public signup stays disabled; verification is not a licence to self-register. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3581ad7c8e
commit
ea28f8aa2e
@@ -0,0 +1,182 @@
|
||||
import { type ReactElement, useState } from "react";
|
||||
import { AuthCard, authStyles, useSingleSubmission } from "./AuthCard.js";
|
||||
|
||||
export interface VerificationNoticeProps {
|
||||
/**
|
||||
* The address the person just tried to sign in with, when this notice followed a sign-in attempt.
|
||||
*
|
||||
* Present only because they typed it moments ago: it is never learned from the server, and the
|
||||
* resend it enables reveals nothing, because the server answers identically for an address with no
|
||||
* account (ADR 0038).
|
||||
*/
|
||||
readonly email?: string;
|
||||
readonly onResend: (email: string, signal: AbortSignal) => Promise<void>;
|
||||
readonly onReturnToSignIn: () => void;
|
||||
}
|
||||
|
||||
/** Shown after a correct password on an address that has not been verified. No session exists at
|
||||
* this point — the identity system refused to create one — so nothing here is authenticated. */
|
||||
export function VerificationNotice(props: VerificationNoticeProps): ReactElement {
|
||||
const [email, setEmail] = useState(props.email ?? "");
|
||||
const [sent, setSent] = useState(false);
|
||||
const { errorMessage, pending, submit } = useSingleSubmission(
|
||||
"Unable to send a verification email right now.",
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthCard
|
||||
heading="Verify your email"
|
||||
headingId="ekklesia-verify-heading"
|
||||
testId="host-verification-required"
|
||||
>
|
||||
<p style={authStyles.introduction}>
|
||||
Your password was accepted, but this address has not been confirmed yet. Open the
|
||||
verification link we sent you, then sign in again.
|
||||
</p>
|
||||
{sent ? (
|
||||
<p data-testid="verification-resent" style={authStyles.introduction}>
|
||||
If an unverified Ekklesia account exists for that address, a new verification link is on
|
||||
its way. It expires in one hour.
|
||||
</p>
|
||||
) : (
|
||||
<form
|
||||
aria-busy={pending}
|
||||
onSubmit={(event) =>
|
||||
void submit(event, async (signal) => {
|
||||
await props.onResend(email, signal);
|
||||
setSent(true);
|
||||
})
|
||||
}
|
||||
style={authStyles.form}
|
||||
>
|
||||
<label style={authStyles.field}>
|
||||
<span style={authStyles.label}>Email</span>
|
||||
<input
|
||||
aria-describedby={errorMessage ? "ekklesia-verify-error" : undefined}
|
||||
autoComplete="username"
|
||||
data-testid="verification-email"
|
||||
disabled={pending}
|
||||
inputMode="email"
|
||||
name="email"
|
||||
onChange={(event) => setEmail(event.currentTarget.value)}
|
||||
required
|
||||
style={authStyles.input}
|
||||
type="email"
|
||||
value={email}
|
||||
/>
|
||||
</label>
|
||||
{errorMessage ? (
|
||||
<p id="ekklesia-verify-error" role="alert" style={authStyles.error}>
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : undefined}
|
||||
<button
|
||||
data-testid="verification-resend"
|
||||
disabled={pending}
|
||||
style={authStyles.button}
|
||||
type="submit"
|
||||
>
|
||||
{pending ? "Sending…" : "Send a new verification link"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<button
|
||||
data-testid="verification-return-to-sign-in"
|
||||
onClick={props.onReturnToSignIn}
|
||||
style={authStyles.secondaryAction}
|
||||
type="button"
|
||||
>
|
||||
Back to sign in
|
||||
</button>
|
||||
</AuthCard>
|
||||
);
|
||||
}
|
||||
|
||||
export interface VerificationResultProps {
|
||||
readonly outcome:
|
||||
| { readonly kind: "verified" }
|
||||
| { readonly kind: "failed"; readonly expired: boolean };
|
||||
readonly onReturnToSignIn: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an emailed verification link lands.
|
||||
*
|
||||
* The identity system has already acted by the time this renders — it verified the address and
|
||||
* redirected here, or refused and redirected here with a reason. Nothing on this screen is
|
||||
* authenticated: following a link deliberately does not mint a session (ADR 0038), so the only
|
||||
* offer is to go and sign in.
|
||||
*/
|
||||
export function VerificationResult(props: VerificationResultProps): ReactElement {
|
||||
if (props.outcome.kind === "verified") {
|
||||
return (
|
||||
<AuthCard
|
||||
heading="Email verified"
|
||||
headingId="ekklesia-verified-heading"
|
||||
testId="host-verification-succeeded"
|
||||
>
|
||||
<p style={authStyles.introduction}>
|
||||
This address is confirmed. Sign in to Ekklesia to continue.
|
||||
</p>
|
||||
<button
|
||||
data-testid="verified-continue"
|
||||
onClick={props.onReturnToSignIn}
|
||||
style={authStyles.button}
|
||||
type="button"
|
||||
>
|
||||
Go to sign in
|
||||
</button>
|
||||
</AuthCard>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AuthCard
|
||||
heading={props.outcome.expired ? "This link has expired" : "This link is not valid"}
|
||||
headingId="ekklesia-verify-failed-heading"
|
||||
testId="host-verification-failed"
|
||||
>
|
||||
<p style={authStyles.introduction}>
|
||||
{props.outcome.expired
|
||||
? "Verification links last one hour. Sign in with your password and Ekklesia will send a new one."
|
||||
: "This verification link could not be used. Sign in with your password and Ekklesia will send a new one."}
|
||||
</p>
|
||||
<button
|
||||
data-testid="verification-failed-continue"
|
||||
onClick={props.onReturnToSignIn}
|
||||
style={authStyles.button}
|
||||
type="button"
|
||||
>
|
||||
Go to sign in
|
||||
</button>
|
||||
</AuthCard>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ResetLinkInvalidProps {
|
||||
readonly onReturnToSignIn: () => void;
|
||||
}
|
||||
|
||||
/** Where an expired or malformed password-reset link lands. It offers the recovery form again
|
||||
* rather than a dead end, and says nothing about whether an account exists. */
|
||||
export function ResetLinkInvalid(props: ResetLinkInvalidProps): ReactElement {
|
||||
return (
|
||||
<AuthCard
|
||||
heading="This reset link is not valid"
|
||||
headingId="ekklesia-reset-invalid-heading"
|
||||
testId="host-reset-link-invalid"
|
||||
>
|
||||
<p style={authStyles.introduction}>
|
||||
Password reset links last one hour and can be used once. Request a new one from the sign-in
|
||||
page.
|
||||
</p>
|
||||
<button
|
||||
data-testid="reset-invalid-continue"
|
||||
onClick={props.onReturnToSignIn}
|
||||
style={authStyles.button}
|
||||
type="button"
|
||||
>
|
||||
Go to sign in
|
||||
</button>
|
||||
</AuthCard>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user