feat: verify email addresses and recover forgotten passwords
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m42s

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:
ekklesia
2026-08-25 07:21:17 -05:00
co-authored by Claude Opus 5
parent 3581ad7c8e
commit ea28f8aa2e
52 changed files with 5675 additions and 197 deletions
+155
View File
@@ -0,0 +1,155 @@
import { type FormEvent, type ReactElement, useEffect, useRef, useState } from "react";
import { HostAuthError } from "../auth-protocol.js";
/**
* The shared visual language of every host-owned auth screen.
*
* Exported so the login, recovery, reset, and verification screens are literally the same card
* rather than four that drift apart. Modules never render any of them.
*/
export const authStyles = {
button: {
background: "#1c1917",
border: "1px solid #1c1917",
borderRadius: "0.375rem",
color: "#fafaf9",
cursor: "pointer",
font: "inherit",
fontWeight: 600,
padding: "0.625rem 0.875rem",
},
card: {
background: "#ffffff",
border: "1px solid #e7e5e4",
borderRadius: "0.75rem",
boxShadow: "0 12px 32px rgb(28 25 23 / 0.08)",
display: "grid",
gap: "1rem",
maxWidth: "24rem",
padding: "1.5rem",
width: "100%",
},
error: { color: "#9f1239", margin: 0 },
field: { display: "grid", gap: "0.375rem" },
form: { display: "grid", gap: "1rem" },
heading: { fontSize: "1.5rem", margin: 0 },
host: {
alignItems: "center",
background: "#fafaf9",
color: "#1c1917",
display: "flex",
fontFamily: "system-ui, sans-serif",
justifyContent: "center",
minHeight: "100vh",
padding: "1rem",
},
input: {
border: "1px solid #a8a29e",
borderRadius: "0.375rem",
font: "inherit",
padding: "0.625rem 0.75rem",
width: "100%",
},
introduction: { color: "#57534e", margin: 0 },
label: { fontWeight: 600 },
secondaryAction: {
background: "transparent",
border: "none",
color: "#1c1917",
cursor: "pointer",
font: "inherit",
justifySelf: "start",
padding: 0,
textDecoration: "underline",
},
} as const;
export interface AuthCardProps {
readonly headingId: string;
readonly heading: string;
readonly testId: string;
readonly children: React.ReactNode;
}
/** One centred card on the sign-in background, with a labelled region for assistive technology. */
export function AuthCard(props: AuthCardProps): ReactElement {
return (
<main data-testid={props.testId} style={authStyles.host}>
<section aria-labelledby={props.headingId} style={authStyles.card}>
<h1 id={props.headingId} style={authStyles.heading}>
{props.heading}
</h1>
{props.children}
</section>
</main>
);
}
export interface UseSingleSubmissionResult {
readonly pending: boolean;
readonly errorMessage: string | undefined;
readonly setErrorMessage: (message: string | undefined) => void;
/** Runs `operation` at most once at a time. A second submit while one is in flight is dropped. */
readonly submit: (
event: FormEvent<HTMLFormElement>,
operation: (signal: AbortSignal) => Promise<void>,
) => Promise<void>;
}
/**
* The submission discipline every auth form needs, in one place.
*
* A `ref` closes the same-tick gap before React commits `pending`, which is what actually prevents a
* double submission — a disabled attribute alone cannot, because it is not applied until after the
* commit. Every one of these forms triggers an email or changes a credential, so a duplicate is not
* merely untidy.
*/
export function useSingleSubmission(fallbackMessage: string): UseSingleSubmissionResult {
const [pending, setPending] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
const pendingRef = useRef(false);
const attemptRef = useRef<AbortController | undefined>(undefined);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
attemptRef.current?.abort();
};
}, []);
const submit = async (
event: FormEvent<HTMLFormElement>,
operation: (signal: AbortSignal) => Promise<void>,
): Promise<void> => {
event.preventDefault();
if (pendingRef.current) {
return;
}
pendingRef.current = true;
setPending(true);
setErrorMessage(undefined);
const attempt = new AbortController();
attemptRef.current = attempt;
try {
await operation(attempt.signal);
} catch (error) {
if (!attempt.signal.aborted && mountedRef.current) {
// A `HostAuthError` message is fixed text this package wrote. Anything else is an unknown
// thrown value whose message could contain a URL or a provider detail, so it is replaced.
setErrorMessage(error instanceof HostAuthError ? error.message : fallbackMessage);
}
} finally {
if (attemptRef.current === attempt) {
attemptRef.current = undefined;
pendingRef.current = false;
if (mountedRef.current) {
setPending(false);
}
}
}
};
return { errorMessage, pending, setErrorMessage, submit };
}