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
+187
View File
@@ -3,6 +3,19 @@ import { resolveApiUrl } from "./host-protocol.js";
const SIGN_IN_PATH = "/api/auth/sign-in/email";
const SIGN_OUT_PATH = "/api/auth/sign-out";
const REQUEST_PASSWORD_RESET_PATH = "/api/auth/request-password-reset";
const RESET_PASSWORD_PATH = "/api/auth/reset-password";
const SEND_VERIFICATION_EMAIL_PATH = "/api/auth/send-verification-email";
/**
* The identity system's own password bounds, mirrored here for immediate form feedback only.
*
* The server re-checks both from the same configuration and remains authoritative; this exists so
* someone typing a five-character password is told before a round trip, not so the browser decides
* anything.
*/
export const HOST_PASSWORD_MIN_LENGTH = 8;
export const HOST_PASSWORD_MAX_LENGTH = 128;
export interface HostSignInCredentials {
readonly email: string;
@@ -11,12 +24,18 @@ export interface HostSignInCredentials {
export type HostAuthErrorCode =
| "AUTH_CREDENTIALS_INVALID"
| "AUTH_EMAIL_NOT_VERIFIED"
| "AUTH_REQUEST_FAILED"
| "AUTH_RESET_TOKEN_INVALID"
| "AUTH_PASSWORD_INVALID"
| "AUTH_SIGN_OUT_FAILED";
const errorMessages: Readonly<Record<HostAuthErrorCode, string>> = Object.freeze({
AUTH_CREDENTIALS_INVALID: "Email or password is incorrect.",
AUTH_EMAIL_NOT_VERIFIED: "Verify your email address before signing in.",
AUTH_PASSWORD_INVALID: `Choose a password between ${HOST_PASSWORD_MIN_LENGTH} and ${HOST_PASSWORD_MAX_LENGTH} characters.`,
AUTH_REQUEST_FAILED: "Unable to sign in right now.",
AUTH_RESET_TOKEN_INVALID: "This password reset link is no longer valid. Request a new one.",
AUTH_SIGN_OUT_FAILED: "Unable to sign out right now.",
});
@@ -64,10 +83,33 @@ async function postJson(
}
}
/**
* Reads the identity system's own error code out of a failed response.
*
* Only the `code` field is ever taken, and only to choose between this module's own fixed messages —
* the response body itself is never surfaced, so a future change to the identity system's prose can
* never leak into the interface. A body that is missing, malformed, or not JSON simply yields
* `undefined` and the caller falls back to its generic failure.
*/
async function failureCode(response: Response): Promise<string | undefined> {
try {
const body = (await response.json()) as { readonly code?: unknown } | null;
return typeof body?.code === "string" ? body.code : undefined;
} catch {
return undefined;
}
}
/**
* Uses Ekklesia's existing Better Auth transport without exposing Better Auth to browser packages.
* Success means only that the mutation succeeded; the shell must resolve `/api/v1/principal` before
* it renders authenticated content rather than trusting a response token or user object.
*
* A correct password on an unverified address is **not** a success and mints no session — the
* identity system refuses it before creating one (ADR 0038). It is reported as its own error so the
* shell can offer verification rather than claiming the password was wrong, which would be both
* untrue and unactionable. Reaching that state already required the correct password, so it reveals
* nothing an attacker did not supply.
*/
export async function signInWithEmail(
config: EkklesiaHostConfig,
@@ -75,6 +117,9 @@ export async function signInWithEmail(
signal?: AbortSignal,
): Promise<void> {
const response = await postJson(config, SIGN_IN_PATH, credentials, "AUTH_REQUEST_FAILED", signal);
if (response.status === 403 && (await failureCode(response)) === "EMAIL_NOT_VERIFIED") {
throw new HostAuthError("AUTH_EMAIL_NOT_VERIFIED");
}
if (response.status === 400 || response.status === 401) {
throw new HostAuthError("AUTH_CREDENTIALS_INVALID");
}
@@ -83,6 +128,90 @@ export async function signInWithEmail(
}
}
/**
* Asks for a password-reset message.
*
* Resolves for **every** address the identity system accepts as syntactically valid, whether or not
* an account exists, whether or not that account is verified, and whether or not the message could
* actually be delivered. That is the whole point: the caller has no way to tell the cases apart, so
* neither does anyone using the form. The identity system answers the same way in each case, and a
* delivery failure is reported to the operator out of band instead (ADR 0038).
*
* A transport failure or a rate-limit refusal is still surfaced — those describe this request, not
* the existence of an account.
*/
export async function requestPasswordReset(
config: EkklesiaHostConfig,
email: string,
signal?: AbortSignal,
): Promise<void> {
const response = await postJson(
config,
REQUEST_PASSWORD_RESET_PATH,
{ email },
"AUTH_REQUEST_FAILED",
signal,
);
if (!response.ok) {
throw new HostAuthError("AUTH_REQUEST_FAILED");
}
}
/**
* Completes a password reset with the token from an emailed link.
*
* The token travels in the request body, never in a URL this function builds, and is not retained
* afterwards. The new password is likewise only ever a body field.
*/
export async function resetPassword(
config: EkklesiaHostConfig,
input: { readonly token: string; readonly newPassword: string },
signal?: AbortSignal,
): Promise<void> {
const response = await postJson(
config,
RESET_PASSWORD_PATH,
{ newPassword: input.newPassword, token: input.token },
"AUTH_REQUEST_FAILED",
signal,
);
if (response.ok) {
return;
}
const code = await failureCode(response);
if (code === "PASSWORD_TOO_SHORT" || code === "PASSWORD_TOO_LONG") {
throw new HostAuthError("AUTH_PASSWORD_INVALID");
}
if (code === "INVALID_TOKEN") {
throw new HostAuthError("AUTH_RESET_TOKEN_INVALID");
}
throw new HostAuthError("AUTH_REQUEST_FAILED");
}
/**
* Asks for a fresh verification message.
*
* Enumeration-safe in exactly the same way as {@link requestPasswordReset}: the identity system
* answers identically for an unknown address, an already-verified one, and an unverified one, and
* this function does not distinguish them either.
*/
export async function resendVerificationEmail(
config: EkklesiaHostConfig,
email: string,
signal?: AbortSignal,
): Promise<void> {
const response = await postJson(
config,
SEND_VERIFICATION_EMAIL_PATH,
{ email },
"AUTH_REQUEST_FAILED",
signal,
);
if (!response.ok) {
throw new HostAuthError("AUTH_REQUEST_FAILED");
}
}
/** Invalidates the server session. Hiding the shell without this successful mutation is not logout. */
export async function signOutHost(config: EkklesiaHostConfig, signal?: AbortSignal): Promise<void> {
const response = await postJson(config, SIGN_OUT_PATH, {}, "AUTH_SIGN_OUT_FAILED", signal);
@@ -90,3 +219,61 @@ export async function signOutHost(config: EkklesiaHostConfig, signal?: AbortSign
throw new HostAuthError("AUTH_SIGN_OUT_FAILED");
}
}
/**
* The two query values Ekklesia's own emailed links carry back to the Dashboard.
*
* Kept in step with `core/src/identity/notifications.ts`, which builds the links. They are query
* parameters on the Dashboard root rather than distinct paths because the shell is a static bundle:
* a deep path would oblige every deployment's static host to rewrite unknown paths to `index.html`
* before an emailed link resolved at all.
*/
const AUTH_LANDING_PARAMETER = "ekklesia_auth";
const VERIFY_EMAIL_LANDING = "verify-email";
const RESET_PASSWORD_LANDING = "reset-password";
/** What an emailed link asked this browser to show. */
export type HostAuthLanding =
| { readonly kind: "verified" }
| { readonly kind: "verification-failed"; readonly expired: boolean }
| { readonly kind: "reset-password"; readonly token: string }
| { readonly kind: "reset-link-invalid" };
/** A reset token is opaque to this shell, but it still has to end up in a URL and a request body, so
* anything that is not a plain bounded token is treated as no token at all. */
const RESET_TOKEN_PATTERN = /^[\w-]{1,256}$/u;
/**
* Classifies the current location's query into a host-owned auth screen.
*
* A pure function of a query string: it reads no global, so the shell's one browser-global boundary
* stays in `browser/main.tsx`. It recognizes **only** Ekklesia's own two landings and ignores
* everything else, so an arbitrary query cannot steer the shell into an auth state — and it produces
* no URL and no destination of any kind, so there is nothing here an open redirect could ride on.
*/
export function readAuthLanding(search: string): HostAuthLanding | undefined {
let parameters: URLSearchParams;
try {
parameters = new URLSearchParams(search);
} catch {
return undefined;
}
const landing = parameters.get(AUTH_LANDING_PARAMETER);
const error = parameters.get("error");
if (landing === VERIFY_EMAIL_LANDING) {
if (error !== null) {
// Better Auth distinguishes an expired token from a malformed or unknown one, and so does
// this: "your link expired, here is how to get another" is actionable where "invalid" is not.
return { expired: error === "TOKEN_EXPIRED", kind: "verification-failed" };
}
return { kind: "verified" };
}
if (landing === RESET_PASSWORD_LANDING) {
const token = parameters.get("token");
if (error !== null || token === null || !RESET_TOKEN_PATTERN.test(token)) {
return { kind: "reset-link-invalid" };
}
return { kind: "reset-password", token };
}
return undefined;
}
+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 };
}
@@ -0,0 +1,103 @@
import { type ReactElement, useState } from "react";
import { AuthCard, authStyles, useSingleSubmission } from "./AuthCard.js";
export interface ForgotPasswordFormProps {
/** Resolves for every syntactically valid address. See `requestPasswordReset`. */
readonly onRequestReset: (email: string, signal: AbortSignal) => Promise<void>;
readonly onReturnToSignIn: () => void;
}
/**
* The host-owned "forgot password" screen.
*
* Its completion state is deliberately the same sentence for every address. The form does not know,
* and cannot be made to reveal, whether an account exists — the server answers identically, this
* component renders identically, and a delivery failure reaches the operator out of band rather than
* the person at the keyboard (ADR 0038).
*/
export function ForgotPasswordForm(props: ForgotPasswordFormProps): ReactElement {
const [email, setEmail] = useState("");
const [submitted, setSubmitted] = useState(false);
const { errorMessage, pending, submit } = useSingleSubmission(
"Unable to send a reset email right now.",
);
if (submitted) {
return (
<AuthCard
heading="Check your email"
headingId="ekklesia-forgot-sent-heading"
testId="host-forgot-password-sent"
>
<p style={authStyles.introduction}>
If an Ekklesia account exists for that address, a message with a link to choose a new
password is on its way. The link expires in one hour.
</p>
<button
data-testid="forgot-return-to-sign-in"
onClick={props.onReturnToSignIn}
style={authStyles.button}
type="button"
>
Back to sign in
</button>
</AuthCard>
);
}
return (
<AuthCard
heading="Reset your password"
headingId="ekklesia-forgot-heading"
testId="host-forgot-password"
>
<p style={authStyles.introduction}>
Enter the email address for your Ekklesia account and we will send a link to choose a new
password.
</p>
<form
aria-busy={pending}
onSubmit={(event) =>
void submit(event, async (signal) => {
await props.onRequestReset(email, signal);
setSubmitted(true);
})
}
style={authStyles.form}
>
<label style={authStyles.field}>
<span style={authStyles.label}>Email</span>
<input
aria-describedby={errorMessage ? "ekklesia-forgot-error" : undefined}
autoComplete="username"
data-testid="forgot-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-forgot-error" role="alert" style={authStyles.error}>
{errorMessage}
</p>
) : undefined}
<button disabled={pending} style={authStyles.button} type="submit">
{pending ? "Sending…" : "Send reset link"}
</button>
<button
data-testid="forgot-cancel"
onClick={props.onReturnToSignIn}
style={authStyles.secondaryAction}
type="button"
>
Back to sign in
</button>
</form>
</AuthCard>
);
}
+106 -1
View File
@@ -2,7 +2,11 @@ import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
import { type ReactElement, useCallback, useEffect, useRef, useState } from "react";
import {
HostAuthError,
type HostAuthLanding,
type HostSignInCredentials,
requestPasswordReset,
resendVerificationEmail,
resetPassword,
signInWithEmail,
signOutHost,
} from "../auth-protocol.js";
@@ -15,8 +19,11 @@ import {
loadHostPrincipal,
} from "../host-protocol.js";
import { DevicePanel } from "./DevicePanel.js";
import { ForgotPasswordForm } from "./ForgotPasswordForm.js";
import { LoginForm } from "./LoginForm.js";
import { ModuleUiFrame } from "./ModuleUiFrame.js";
import { ResetPasswordForm } from "./ResetPasswordForm.js";
import { ResetLinkInvalid, VerificationNotice, VerificationResult } from "./VerificationScreens.js";
type AuthenticatedPrincipal = Extract<
HostBootstrap["principal"],
@@ -37,11 +44,61 @@ type ResolvedBootstrapState = Extract<
{ readonly status: "ready" | "anonymous" | "access-denied" | "failed" }
>;
/**
* Which host-owned screen an unauthenticated visitor sees.
*
* A small explicit union rather than a client-side router: these four screens are the entirety of
* the platform's anonymous surface, and a routing library would be a dependency and an extra URL
* authority for a state machine that fits on a page. Only `reset-password` carries anything, and
* what it carries came from an emailed link the shell has already stripped from the address bar.
*
* None of these states is authenticated, and none can become authenticated: the shell renders the
* Dashboard only after `/api/v1/principal` says so, exactly as before.
*/
type AuthView =
| { readonly kind: "sign-in" }
| { readonly kind: "forgot-password" }
| { readonly kind: "verification-required"; readonly email: string }
| { readonly kind: "verification-result"; readonly outcome: VerificationOutcome }
| { readonly kind: "reset-password"; readonly token: string }
| { readonly kind: "reset-link-invalid" };
type VerificationOutcome =
| { readonly kind: "verified" }
| { readonly kind: "failed"; readonly expired: boolean };
function authViewFromLanding(landing: HostAuthLanding | undefined): AuthView {
if (!landing) {
return { kind: "sign-in" };
}
if (landing.kind === "verified") {
return { kind: "verification-result", outcome: { kind: "verified" } };
}
if (landing.kind === "verification-failed") {
return {
kind: "verification-result",
outcome: { expired: landing.expired, kind: "failed" },
};
}
if (landing.kind === "reset-password") {
return { kind: "reset-password", token: landing.token };
}
return { kind: "reset-link-invalid" };
}
export interface HostAppProps {
/** Must be a stable reference: bootstrap re-runs whenever it changes, so a fresh object literal
* built during render would re-fetch forever. The browser entry point builds it once at module
* scope. */
readonly config: EkklesiaHostConfig;
/**
* What an emailed verification or reset link asked this browser to show, already parsed.
*
* Supplied by the entry point rather than read here, so this component still touches no browser
* global. It only ever selects among the host's own anonymous screens: it can name no destination,
* so there is nothing an open redirect could use.
*/
readonly authLanding?: HostAuthLanding;
/** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */
readonly importModule?: ModuleUiImporter;
}
@@ -201,6 +258,10 @@ export function HostApp(props: HostAppProps): ReactElement {
const generationRef = useRef(0);
const mountedRef = useRef(true);
const { config, importModule } = props;
// Initialized once from the landing the entry point parsed. A later sign-out or a "back to sign
// in" action moves it back to the form; nothing re-reads the URL, so a stale emailed parameter
// cannot resurrect a screen the person has left.
const [authView, setAuthView] = useState<AuthView>(() => authViewFromLanding(props.authLanding));
const beginTransition = useCallback(() => {
activeTransitionRef.current?.abort();
@@ -306,6 +367,10 @@ export function HostApp(props: HostAppProps): ReactElement {
if (!isCurrent(controller, generation)) {
return;
}
// Returning to the anonymous shell always returns to the sign-in form. Without this, signing
// out while a verification or reset screen was the last anonymous view would show that screen
// again, describing an emailed link the person has long since finished with.
setAuthView({ kind: "sign-in" });
setState(
principal.kind === "anonymous" ? { status: "anonymous" } : { status: "sign-out-failed" },
);
@@ -349,6 +414,7 @@ export function HostApp(props: HostAppProps): ReactElement {
transition.controller.abort();
activeTransitionRef.current = undefined;
setSelectedKey(undefined);
setAuthView({ kind: "sign-in" });
setState({ status: "anonymous" });
}
})
@@ -382,7 +448,46 @@ export function HostApp(props: HostAppProps): ReactElement {
return <HostMessage testId="host-loading">Loading Ekklesia…</HostMessage>;
}
if (state.status === "anonymous") {
return <LoginForm onAuthenticate={authenticate} />;
const returnToSignIn = (): void => setAuthView({ kind: "sign-in" });
if (authView.kind === "forgot-password") {
return (
<ForgotPasswordForm
onRequestReset={(email, signal) => requestPasswordReset(config, email, signal)}
onReturnToSignIn={returnToSignIn}
/>
);
}
if (authView.kind === "verification-required") {
return (
<VerificationNotice
email={authView.email}
onResend={(email, signal) => resendVerificationEmail(config, email, signal)}
onReturnToSignIn={returnToSignIn}
/>
);
}
if (authView.kind === "verification-result") {
return <VerificationResult onReturnToSignIn={returnToSignIn} outcome={authView.outcome} />;
}
if (authView.kind === "reset-password") {
return (
<ResetPasswordForm
onResetPassword={(input, signal) => resetPassword(config, input, signal)}
onReturnToSignIn={returnToSignIn}
token={authView.token}
/>
);
}
if (authView.kind === "reset-link-invalid") {
return <ResetLinkInvalid onReturnToSignIn={returnToSignIn} />;
}
return (
<LoginForm
onAuthenticate={authenticate}
onEmailNotVerified={(email) => setAuthView({ email, kind: "verification-required" })}
onForgotPassword={() => setAuthView({ kind: "forgot-password" })}
/>
);
}
if (state.status === "failed") {
return (
+72 -104
View File
@@ -7,61 +7,23 @@ import {
useState,
} from "react";
import { HostAuthError, type HostSignInCredentials } from "../auth-protocol.js";
import { AuthCard, authStyles } from "./AuthCard.js";
export interface LoginFormProps {
readonly onAuthenticate: (
credentials: HostSignInCredentials,
signal: AbortSignal,
) => Promise<void>;
/** Opens the host-owned password-recovery screen. */
readonly onForgotPassword: () => void;
/**
* Called when the credentials were correct but the address is unverified, so the shell can offer
* verification instead of an error. The address is passed through because the person just typed
* it — it is never learned from a response.
*/
readonly onEmailNotVerified: (email: string) => void;
}
const loginStyles = {
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 },
} as const;
/** Host-owned email/password sign-in. Modules never render or call this component. */
export function LoginForm(props: LoginFormProps): ReactElement {
const [email, setEmail] = useState("");
@@ -108,11 +70,18 @@ export function LoginForm(props: LoginFormProps): ReactElement {
setPassword("");
await authentication;
} catch (error) {
if (!attempt.signal.aborted && mountedRef.current) {
setErrorMessage(
error instanceof HostAuthError ? error.message : "Unable to sign in right now.",
);
if (attempt.signal.aborted || !mountedRef.current) {
return;
}
if (error instanceof HostAuthError && error.code === "AUTH_EMAIL_NOT_VERIFIED") {
// Not an error state on this form: the password was right, and the next thing the person
// needs is a verification link rather than a red message under a password box.
props.onEmailNotVerified(email);
return;
}
setErrorMessage(
error instanceof HostAuthError ? error.message : "Unable to sign in right now.",
);
} finally {
if (attemptRef.current === attempt) {
attemptRef.current = undefined;
@@ -142,59 +111,58 @@ export function LoginForm(props: LoginFormProps): ReactElement {
};
return (
<main data-testid="host-login" style={loginStyles.host}>
<section aria-labelledby="ekklesia-login-heading" style={loginStyles.card}>
<h1 id="ekklesia-login-heading" style={loginStyles.heading}>
Sign in to Ekklesia
</h1>
<p style={loginStyles.introduction}>Use your existing Ekklesia account.</p>
<form
aria-busy={pending}
onKeyDown={submitOnEnter}
onSubmit={submit}
style={loginStyles.form}
<AuthCard heading="Sign in to Ekklesia" headingId="ekklesia-login-heading" testId="host-login">
<p style={authStyles.introduction}>Use your existing Ekklesia account.</p>
<form aria-busy={pending} onKeyDown={submitOnEnter} onSubmit={submit} style={authStyles.form}>
<label style={authStyles.field}>
<span style={authStyles.label}>Email</span>
<input
autoComplete="username"
data-testid="login-email"
disabled={pending}
inputMode="email"
name="email"
onChange={(event) => setEmail(event.currentTarget.value)}
required
style={authStyles.input}
type="email"
value={email}
/>
</label>
<label style={authStyles.field}>
<span style={authStyles.label}>Password</span>
<input
aria-describedby={errorMessage ? "ekklesia-login-error" : undefined}
autoComplete="current-password"
data-testid="login-password"
disabled={pending}
name="password"
onChange={(event) => setPassword(event.currentTarget.value)}
ref={passwordRef}
required
style={authStyles.input}
type="password"
value={password}
/>
</label>
{errorMessage ? (
<p id="ekklesia-login-error" role="alert" style={authStyles.error}>
{errorMessage}
</p>
) : undefined}
<button disabled={pending} style={authStyles.button} type="submit">
{pending ? "Signing in…" : "Sign in"}
</button>
<button
data-testid="login-forgot-password"
disabled={pending}
onClick={props.onForgotPassword}
style={authStyles.secondaryAction}
type="button"
>
<label style={loginStyles.field}>
<span style={loginStyles.label}>Email</span>
<input
autoComplete="username"
data-testid="login-email"
disabled={pending}
inputMode="email"
name="email"
onChange={(event) => setEmail(event.currentTarget.value)}
required
style={loginStyles.input}
type="email"
value={email}
/>
</label>
<label style={loginStyles.field}>
<span style={loginStyles.label}>Password</span>
<input
aria-describedby={errorMessage ? "ekklesia-login-error" : undefined}
autoComplete="current-password"
data-testid="login-password"
disabled={pending}
name="password"
onChange={(event) => setPassword(event.currentTarget.value)}
ref={passwordRef}
required
style={loginStyles.input}
type="password"
value={password}
/>
</label>
{errorMessage ? (
<p id="ekklesia-login-error" role="alert" style={loginStyles.error}>
{errorMessage}
</p>
) : undefined}
<button disabled={pending} style={loginStyles.button} type="submit">
{pending ? "Signing in…" : "Sign in"}
</button>
</form>
</section>
</main>
Forgot password?
</button>
</form>
</AuthCard>
);
}
@@ -0,0 +1,145 @@
import { type ReactElement, useState } from "react";
import { HOST_PASSWORD_MAX_LENGTH, HOST_PASSWORD_MIN_LENGTH } from "../auth-protocol.js";
import { AuthCard, authStyles, useSingleSubmission } from "./AuthCard.js";
const PASSWORD_MISMATCH_MESSAGE = "The two passwords do not match.";
export interface ResetPasswordFormProps {
/** The opaque token captured from the emailed link. Never rendered and never stored. */
readonly token: string;
readonly onResetPassword: (
input: { readonly token: string; readonly newPassword: string },
signal: AbortSignal,
) => Promise<void>;
readonly onReturnToSignIn: () => void;
}
/**
* The host-owned "choose a new password" screen.
*
* The token reaches this component as a prop and goes straight into a request body. It is never
* placed in a URL this component builds, never written to storage, and never rendered — and the
* shell strips it from the address bar before this screen is even mounted (`browser/main.tsx`).
*
* Confirmation is required here even though the server needs one value: mistyping a password you
* cannot see, in the one flow whose whole purpose is that you have lost access, locks you out again.
* The mismatch check is a courtesy in the browser; the length policy is echoed for immediate
* feedback but the server enforces the identical bounds from the same configuration and remains
* authoritative.
*/
export function ResetPasswordForm(props: ResetPasswordFormProps): ReactElement {
const [password, setPassword] = useState("");
const [confirmation, setConfirmation] = useState("");
const [completed, setCompleted] = useState(false);
const { errorMessage, pending, setErrorMessage, submit } = useSingleSubmission(
"Unable to set a new password right now.",
);
if (completed) {
return (
<AuthCard
heading="Password updated"
headingId="ekklesia-reset-done-heading"
testId="host-reset-password-complete"
>
<p style={authStyles.introduction}>
Your password has been changed and every existing Ekklesia session for this account has
been signed out. Sign in with the new password.
</p>
<button
data-testid="reset-return-to-sign-in"
onClick={props.onReturnToSignIn}
style={authStyles.button}
type="button"
>
Go to sign in
</button>
</AuthCard>
);
}
return (
<AuthCard
heading="Choose a new password"
headingId="ekklesia-reset-heading"
testId="host-reset-password"
>
<p style={authStyles.introduction}>
Pick a password between {HOST_PASSWORD_MIN_LENGTH} and {HOST_PASSWORD_MAX_LENGTH}{" "}
characters.
</p>
<form
aria-busy={pending}
onSubmit={(event) =>
void submit(event, async (signal) => {
if (password !== confirmation) {
// Rejected before anything is sent: the server cannot detect a typo it never receives,
// and a mismatched confirmation is the one failure the browser genuinely does know
// about. Reported directly rather than thrown, because it is not a protocol failure.
setErrorMessage(PASSWORD_MISMATCH_MESSAGE);
return;
}
await props.onResetPassword({ newPassword: password, token: props.token }, signal);
setPassword("");
setConfirmation("");
setCompleted(true);
})
}
style={authStyles.form}
>
<label style={authStyles.field}>
<span style={authStyles.label}>New password</span>
<input
aria-describedby={errorMessage ? "ekklesia-reset-error" : undefined}
autoComplete="new-password"
data-testid="reset-password"
disabled={pending}
maxLength={HOST_PASSWORD_MAX_LENGTH}
minLength={HOST_PASSWORD_MIN_LENGTH}
name="newPassword"
onChange={(event) => setPassword(event.currentTarget.value)}
required
style={authStyles.input}
type="password"
value={password}
/>
</label>
<label style={authStyles.field}>
<span style={authStyles.label}>Confirm new password</span>
<input
autoComplete="new-password"
data-testid="reset-password-confirmation"
disabled={pending}
maxLength={HOST_PASSWORD_MAX_LENGTH}
minLength={HOST_PASSWORD_MIN_LENGTH}
name="confirmPassword"
onChange={(event) => {
setConfirmation(event.currentTarget.value);
setErrorMessage(undefined);
}}
required
style={authStyles.input}
type="password"
value={confirmation}
/>
</label>
{errorMessage ? (
<p id="ekklesia-reset-error" role="alert" style={authStyles.error}>
{errorMessage}
</p>
) : undefined}
<button disabled={pending} style={authStyles.button} type="submit">
{pending ? "Saving…" : "Set new password"}
</button>
<button
data-testid="reset-cancel"
onClick={props.onReturnToSignIn}
style={authStyles.secondaryAction}
type="button"
>
Back to sign in
</button>
</form>
</AuthCard>
);
}
@@ -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>
);
}
+35 -1
View File
@@ -1,4 +1,5 @@
import { createRoot } from "react-dom/client";
import { type HostAuthLanding, readAuthLanding } from "../auth-protocol.js";
import type { EkklesiaHostConfig } from "../host-protocol.js";
import { HostApp } from "./HostApp.js";
@@ -23,9 +24,42 @@ const config: EkklesiaHostConfig = {
apiBaseUrl: __EKKLESIA_API_BASE_URL__ || window.location.origin,
};
/**
* Captures what an emailed verification or reset link asked for, then removes it from the address
* bar.
*
* A password-reset token is a bearer credential for one account. Leaving it in `location.search`
* would put it in the browser's history, in the tab title's shareable URL, and in the `Referer` of
* anything the page subsequently loaded. `history.replaceState` rewrites the entry in place rather
* than pushing a new one, so the Back button cannot return to the URL that held it. It is
* deliberately *not* stored anywhere afterwards — it lives only in React state for the lifetime of
* the reset form, and never in `localStorage` or `sessionStorage`.
*
* The parse is a pure function that recognizes only Ekklesia's own two landings, so nothing in the
* query string can name a destination.
*/
function captureAuthLanding(): HostAuthLanding | undefined {
const landing = readAuthLanding(window.location.search);
if (!landing) {
return undefined;
}
try {
window.history.replaceState(null, "", window.location.pathname);
} catch {
// A browser that refuses the rewrite (an exotic sandbox, a file: URL) must still be able to
// finish the flow: the token is already captured, and failing here would strand the person on a
// page that cannot do anything.
}
return landing;
}
const authLanding = captureAuthLanding();
const container = document.getElementById("ekklesia-host");
if (!container) {
throw new Error("The Ekklesia shell requires a #ekklesia-host element.");
}
createRoot(container).render(<HostApp config={config} />);
createRoot(container).render(
authLanding ? <HostApp authLanding={authLanding} config={config} /> : <HostApp config={config} />,
);
+481
View File
@@ -0,0 +1,481 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js";
import { HostApp } from "../src/browser/HostApp.js";
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
/**
* The host-owned recovery and verification screens (ADR 0038).
*
* Everything asserted here is a property of the *host's* browser surface: that an unverified account
* never reads as authenticated, that a reset token is never persisted or rendered, that a query
* string can only ever select among Ekklesia's own screens, and that no failure message carries a
* provider detail. The server-side behaviour these screens sit on top of is proven separately, end
* to end, in `apps/api/tests/email-verification.integration.ts`.
*/
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
const apiBaseUrl = "https://ekklesia.example.test";
let container: HTMLDivElement;
let root: Root;
let consoleError: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
window.history.replaceState({}, "", "/");
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
});
afterEach(async () => {
await act(async () => {
root.unmount();
});
container.remove();
consoleError.mockRestore();
localStorage.clear();
sessionStorage.clear();
});
interface RecordedCall {
readonly url: string;
readonly body: string;
}
interface AuthStub {
/** Status for `/api/auth/sign-in/email`. 403 with `EMAIL_NOT_VERIFIED` is the unverified case. */
readonly signIn?: () => Response;
readonly requestPasswordReset?: () => Response;
readonly resetPassword?: () => Response;
readonly sendVerificationEmail?: () => Response;
}
interface Platform {
readonly fetch: typeof fetch;
readonly calls: RecordedCall[];
}
function platform(stub: AuthStub = {}): Platform {
const calls: RecordedCall[] = [];
const implementation = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
calls.push({ body: String(init?.body ?? ""), url });
if (url.endsWith("/api/v1/principal")) {
return Response.json({ kind: "anonymous" });
}
if (url.endsWith("/api/auth/sign-in/email")) {
return (
stub.signIn?.() ?? Response.json({ code: "INVALID_EMAIL_OR_PASSWORD" }, { status: 401 })
);
}
if (url.endsWith("/api/auth/request-password-reset")) {
return (
stub.requestPasswordReset?.() ??
Response.json({ message: "If this email exists in our system…", status: true })
);
}
if (url.endsWith("/api/auth/reset-password")) {
return stub.resetPassword?.() ?? Response.json({ status: true });
}
if (url.endsWith("/api/auth/send-verification-email")) {
return stub.sendVerificationEmail?.() ?? Response.json({ status: true });
}
return new Response("{}", { status: 404 });
});
return { calls, fetch: implementation as unknown as typeof fetch };
}
async function render(config: EkklesiaHostConfig, authLanding?: HostAuthLanding): Promise<void> {
await act(async () => {
root.render(<HostApp config={config} {...(authLanding ? { authLanding } : {})} />);
});
}
function text(): string {
return container.textContent ?? "";
}
function query(testId: string): Element | null {
return container.querySelector(`[data-testid="${testId}"]`);
}
async function setInput(testId: string, value: string): Promise<void> {
const input = query(testId) as HTMLInputElement | null;
if (!input) {
throw new Error(`missing input ${testId}`);
}
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
async function click(testId: string): Promise<void> {
const element = query(testId) as HTMLElement | null;
if (!element) {
throw new Error(`missing element ${testId}`);
}
await act(async () => {
element.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
await Promise.resolve();
});
}
async function submit(hostTestId: string): Promise<void> {
const form = query(hostTestId)?.querySelector("form");
if (!form) {
throw new Error(`missing form in ${hostTestId}`);
}
await act(async () => {
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
await Promise.resolve();
});
}
describe("emailed-link landing parsing", () => {
it("recognizes exactly Ekklesia's own two landings", () => {
expect(readAuthLanding("?ekklesia_auth=verify-email")).toEqual({ kind: "verified" });
expect(readAuthLanding("?ekklesia_auth=verify-email&error=TOKEN_EXPIRED")).toEqual({
expired: true,
kind: "verification-failed",
});
expect(readAuthLanding("?ekklesia_auth=verify-email&error=INVALID_TOKEN")).toEqual({
expired: false,
kind: "verification-failed",
});
expect(readAuthLanding("?ekklesia_auth=reset-password&token=abc123")).toEqual({
kind: "reset-password",
token: "abc123",
});
expect(readAuthLanding("?ekklesia_auth=reset-password&error=INVALID_TOKEN")).toEqual({
kind: "reset-link-invalid",
});
});
it("ignores anything that is not one of them, so a query cannot steer the shell", () => {
for (const search of [
"",
"?",
"?token=abc123",
"?ekklesia_auth=dashboard",
"?ekklesia_auth=",
"?redirect=https://evil.example.test",
"?ekklesia_auth[]=verify-email",
]) {
expect(readAuthLanding(search)).toBeUndefined();
}
});
it("treats a reset landing with no usable token as an invalid link, never as a token", () => {
for (const search of [
"?ekklesia_auth=reset-password",
"?ekklesia_auth=reset-password&token=",
"?ekklesia_auth=reset-password&token=has%20space",
`?ekklesia_auth=reset-password&token=${"a".repeat(257)}`,
]) {
expect(readAuthLanding(search)).toEqual({ kind: "reset-link-invalid" });
}
});
it("names no destination of any kind, so there is nothing to redirect to", () => {
const landing = readAuthLanding(
"?ekklesia_auth=verify-email&callbackURL=https://evil.example.test&next=//evil.example.test",
);
expect(landing).toEqual({ kind: "verified" });
expect(JSON.stringify(landing)).not.toContain("evil");
});
});
describe("unverified sign-in", () => {
const unverified = (): Response =>
Response.json({ code: "EMAIL_NOT_VERIFIED", message: "Email not verified" }, { status: 403 });
it("offers verification instead of a credentials error, and never renders the Dashboard", async () => {
const stub = platform({ signIn: unverified });
await render({ apiBaseUrl, fetch: stub.fetch });
await setInput("login-email", "[email protected]");
await setInput("login-password", "correct horse battery staple");
await submit("host-login");
expect(query("host-verification-required")).not.toBeNull();
expect(query("host-login")).toBeNull();
// Not "your password is wrong": it was right. And not the Dashboard either — the server minted
// no session, so nothing about this state is authenticated.
expect(text()).not.toContain("Email or password is incorrect");
expect(text()).toContain("Your password was accepted");
expect(container.querySelector("aside")).toBeNull();
});
it("resends from the address the person just typed, and says nothing about whether it exists", async () => {
const stub = platform({ signIn: unverified });
await render({ apiBaseUrl, fetch: stub.fetch });
await setInput("login-email", "[email protected]");
await setInput("login-password", "correct horse battery staple");
await submit("host-login");
await submit("host-verification-required");
const resend = stub.calls.find((call) =>
call.url.endsWith("/api/auth/send-verification-email"),
);
expect(resend?.body).toBe(JSON.stringify({ email: "[email protected]" }));
expect(query("verification-resent")).not.toBeNull();
// The wording is a conditional, not a confirmation that an account exists.
expect(text()).toContain("If an unverified Ekklesia account exists");
});
it("keeps the password out of every request but the sign-in itself", async () => {
const stub = platform({ signIn: unverified });
await render({ apiBaseUrl, fetch: stub.fetch });
await setInput("login-email", "[email protected]");
await setInput("login-password", "correct horse battery staple");
await submit("host-login");
await submit("host-verification-required");
for (const call of stub.calls) {
expect(call.url).not.toContain("correct horse");
if (!call.url.endsWith("/api/auth/sign-in/email")) {
expect(call.body).not.toContain("correct horse");
}
}
});
});
describe("forgot password", () => {
it("reaches the recovery form from the sign-in screen and back again", async () => {
const stub = platform();
await render({ apiBaseUrl, fetch: stub.fetch });
await click("login-forgot-password");
expect(query("host-forgot-password")).not.toBeNull();
await click("forgot-cancel");
expect(query("host-login")).not.toBeNull();
});
it("shows one generic completion state, whatever the address", async () => {
const stub = platform();
await render({ apiBaseUrl, fetch: stub.fetch });
await click("login-forgot-password");
await setInput("forgot-email", "[email protected]");
await submit("host-forgot-password");
expect(query("host-forgot-password-sent")).not.toBeNull();
expect(text()).toContain("If an Ekklesia account exists for that address");
// Nothing on the screen distinguishes an address with an account from one without.
expect(text()).not.toContain("[email protected]");
expect(text()).not.toContain("No account");
});
it("submits once even when the form is submitted repeatedly", async () => {
let release: (() => void) | undefined;
const stub = platform({
requestPasswordReset: () => Response.json({ status: true }),
});
const gated = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith("/api/auth/request-password-reset")) {
await new Promise<void>((resolve) => {
release = resolve;
});
}
return (stub.fetch as unknown as typeof fetch)(input, init);
});
await render({ apiBaseUrl, fetch: gated as unknown as typeof fetch });
await click("login-forgot-password");
await setInput("forgot-email", "[email protected]");
await submit("host-forgot-password");
await submit("host-forgot-password");
await submit("host-forgot-password");
release?.();
await act(async () => {
await Promise.resolve();
});
// Every one of these triggers an email. A duplicate is not merely untidy.
const requests = stub.calls.filter((call) =>
call.url.endsWith("/api/auth/request-password-reset"),
);
expect(requests).toHaveLength(1);
});
it("reports a transport failure without a provider detail or a URL", async () => {
const stub = platform({
requestPasswordReset: () => new Response("relay 421 at smtp.example.org", { status: 500 }),
});
await render({ apiBaseUrl, fetch: stub.fetch });
await click("login-forgot-password");
await setInput("forgot-email", "[email protected]");
await submit("host-forgot-password");
expect(text()).toContain("Unable to sign in right now.");
expect(text()).not.toContain("smtp.example.org");
expect(text()).not.toContain("421");
expect(text()).not.toContain(apiBaseUrl);
});
});
describe("reset password", () => {
const landing: HostAuthLanding = { kind: "reset-password", token: "reset-token-abc" };
it("sets a new password and returns cleanly to sign-in", async () => {
const stub = platform();
await render({ apiBaseUrl, fetch: stub.fetch }, landing);
expect(query("host-reset-password")).not.toBeNull();
await setInput("reset-password", "a brand new passphrase");
await setInput("reset-password-confirmation", "a brand new passphrase");
await submit("host-reset-password");
const request = stub.calls.find((call) => call.url.endsWith("/api/auth/reset-password"));
expect(JSON.parse(request?.body ?? "{}")).toEqual({
newPassword: "a brand new passphrase",
token: "reset-token-abc",
});
expect(query("host-reset-password-complete")).not.toBeNull();
expect(text()).toContain(
"every existing Ekklesia session for this account has been signed out",
);
await click("reset-return-to-sign-in");
expect(query("host-login")).not.toBeNull();
});
it("blocks a mismatched confirmation in the browser, before anything is sent", async () => {
const stub = platform();
await render({ apiBaseUrl, fetch: stub.fetch }, landing);
await setInput("reset-password", "a brand new passphrase");
await setInput("reset-password-confirmation", "a brand new passphrasf");
await submit("host-reset-password");
expect(text()).toContain("The two passwords do not match.");
expect(stub.calls.some((call) => call.url.endsWith("/api/auth/reset-password"))).toBe(false);
});
it("surfaces the server's password policy without inventing one of its own", async () => {
const stub = platform({
resetPassword: () => Response.json({ code: "PASSWORD_TOO_SHORT" }, { status: 400 }),
});
await render({ apiBaseUrl, fetch: stub.fetch }, landing);
await setInput("reset-password", "short");
await setInput("reset-password-confirmation", "short");
await submit("host-reset-password");
// The server stays authoritative; the browser only echoes the bounds it was configured with.
expect(text()).toContain("between 8 and 128 characters");
const field = query("reset-password") as HTMLInputElement;
expect(field.minLength).toBe(8);
expect(field.maxLength).toBe(128);
});
it("reports a spent or expired token as a link problem, not a password problem", async () => {
const stub = platform({
resetPassword: () => Response.json({ code: "INVALID_TOKEN" }, { status: 400 }),
});
await render({ apiBaseUrl, fetch: stub.fetch }, landing);
await setInput("reset-password", "a brand new passphrase");
await setInput("reset-password-confirmation", "a brand new passphrase");
await submit("host-reset-password");
expect(text()).toContain("This password reset link is no longer valid");
});
it("never persists the token, and never renders it", async () => {
const stub = platform();
await render({ apiBaseUrl, fetch: stub.fetch }, landing);
await setInput("reset-password", "a brand new passphrase");
await setInput("reset-password-confirmation", "a brand new passphrase");
await submit("host-reset-password");
// A reset token is a bearer credential for one account. It lives in React state for the life of
// the form and nowhere else.
expect(localStorage.length).toBe(0);
expect(sessionStorage.length).toBe(0);
expect(text()).not.toContain("reset-token-abc");
expect(container.innerHTML).not.toContain("reset-token-abc");
expect(window.location.search).toBe("");
for (const call of stub.calls) {
expect(call.url).not.toContain("reset-token-abc");
}
});
it("shows a dead-end-free screen for an invalid reset link", async () => {
await render({ apiBaseUrl, fetch: platform().fetch }, { kind: "reset-link-invalid" });
expect(query("host-reset-link-invalid")).not.toBeNull();
expect(text()).toContain("Password reset links last one hour and can be used once");
await click("reset-invalid-continue");
expect(query("host-login")).not.toBeNull();
});
});
describe("verification landing", () => {
it("confirms success and offers sign-in, without pretending to be signed in", async () => {
await render({ apiBaseUrl, fetch: platform().fetch }, { kind: "verified" });
expect(query("host-verification-succeeded")).not.toBeNull();
expect(text()).toContain("This address is confirmed");
// Following an emailed link mints no session, so the Dashboard must not appear.
expect(container.querySelector("aside")).toBeNull();
await click("verified-continue");
expect(query("host-login")).not.toBeNull();
});
it("says an expired link has expired, which is the actionable version", async () => {
await render(
{ apiBaseUrl, fetch: platform().fetch },
{ expired: true, kind: "verification-failed" },
);
expect(query("host-verification-failed")).not.toBeNull();
expect(text()).toContain("This link has expired");
expect(text()).toContain("Ekklesia will send a new one");
});
it("says an unusable link is invalid rather than guessing that it expired", async () => {
await render(
{ apiBaseUrl, fetch: platform().fetch },
{ expired: false, kind: "verification-failed" },
);
expect(text()).toContain("This link is not valid");
expect(text()).not.toContain("has expired");
});
it("still renders the Dashboard for someone who is already signed in", async () => {
const authenticated = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/principal")) {
return Response.json({ kind: "authenticated", userId: "user-1" });
}
if (url.endsWith("/api/v1/dashboard/navigation")) {
return Response.json({ groups: [], ungrouped: [] });
}
return new Response("{}", { status: 404 });
});
await render(
{ apiBaseUrl, fetch: authenticated as unknown as typeof fetch },
{ kind: "verified" },
);
// The principal is still what decides. A landing parameter never promotes anyone.
expect(query("host-verification-succeeded")).toBeNull();
expect(query("host-login")).toBeNull();
});
});
+9 -2
View File
@@ -295,7 +295,7 @@ describe("Ekklesia shell — browser authentication", () => {
expect(calls).toEqual([`${apiBaseUrl}/api/v1/principal`]);
});
it("renders an accessible semantic email/password form with no signup or dead reset action", async () => {
it("renders an accessible semantic email/password form with a real reset action and no signup", async () => {
const platform = statefulAuthPlatform();
await render({ apiBaseUrl, fetch: platform.fetch });
@@ -312,8 +312,15 @@ describe("Ekklesia shell — browser authentication", () => {
expect(password.labels?.[0]?.textContent).toContain("Password");
expect(submit.textContent).toBe("Sign in");
expect(form?.tagName).toBe("FORM");
// Public registration remains disabled, so the shell still offers no way to ask for an account.
expect(text()).not.toContain("Create account");
expect(text()).not.toContain("Forgot password");
expect(text()).not.toContain("Sign up");
// Password recovery is no longer a dead action: Phase 7C Pass 2 gave it a real backend flow, so
// the control exists and is a real button rather than decorative text.
const forgot = query("login-forgot-password") as HTMLButtonElement;
expect(forgot.tagName).toBe("BUTTON");
expect(forgot.type).toBe("button");
expect(forgot.textContent).toBe("Forgot password?");
});
it("fails invalid credentials generically, clears the password, and never puts it in a URL", async () => {