Skip to content

Sign-in in Your Theme

The admin has a login screen of its own, and a theme can have one too. authClient from plumix/auth/client is the one object a theme’s sign-in page, account menu or plain HTML form calls. It carries every sign-in method the site installed, core’s and every plugin’s, and it handles the base path, the CSRF header and the session for you.

authClient is browser-safe, so an island imports it, and it works during the server render too. It gives you:

  • authClient.signIn.<method>(…), one call per sign-in method: passkey, magicLink and oauth from core, and each installed plugin’s method under the camelCase of its name. A method can add more actions under its own name, such as authClient.passkey.register().
  • authClient.signOut({ redirectTo }), which ends the session.
  • authClient.useSession(), which returns { user, isPending }. It starts from the session the page was rendered with, and it updates after any call that signs someone in or out, with no reload. user carries id, email, name, avatarUrl, role and capabilities, the capabilities the user’s role grants.
  • authClient.useCan(capability), which returns whether the session’s capabilities include capability, such as entry:post:create. It reads useSession(), so the server render and hydration agree, and it turns false on sign-out. authClient.can(capability) is the same check outside a component. Both are false while the session is pending or signed out.
  • authClient.useAuthMethods(), which returns the sign-in methods the site lists, each with its name and label, and the configured OAuth providers.
  • authClient.useAccount(), which returns { account, isPending }: the signed-in user’s own id, name, avatarUrl and meta, or null when signed out. It loads on first use, not in the server render.
  • authClient.account.update({ name, avatarUrl, meta }), which saves the signed-in user’s own profile.
  • authClient.account.setLocale(code), which sets the signed-in user’s locale and resolves data.locale, the enabled locale’s code. A code the site hasn’t enabled resolves error.code locale_not_supported and changes nothing. The page stays in the language it rendered in, so reload or navigate if it should switch.
  • authClient.sessions.list(), .revoke(id) and .revokeOthers(), which list the signed-in user’s sessions and end the ones on other devices.
  • authClient.passkey.list(), .rename(id, name) and .remove(id), which manage the signed-in user’s own passkeys beside passkey.register().
  • authClient.oauth.accounts(), which resolves the signed-in user’s linked OAuth accounts, each with its id, provider and linkedAt as a Date. It keeps no cache, so call it again after a change.
  • authClient.oauth.unlink(provider), which removes the signed-in user’s link to provider and resolves { provider }. It refuses to remove the user’s last way to sign in: with no passkey, no other linked provider and no email-based method such as a magic link on the site, it resolves error.code last_sign_in_method and the link stays. A provider the user hasn’t linked resolves not_found. On a runtime that refuses the oauthLinking area, such as the Cloudflare demo, both calls resolve forbidden.
  • authClient.url(target, { redirectTo }), the base-pathed URL of a method’s route or of sign-out, for a plain <form action> or <a href>.

Every call resolves { data, error } and never throws for an expected failure. error is { code, message }. code is the server’s code, such as wrong_code, or one the browser hit itself, such as network_error or user_cancelled. message is a Lingui message descriptor: message.id is the key in your own catalog, and message.message is the English text.

Plumix ships no sign-in components. You write the markup, and authClient does the calls. Every sample on this page is a file of the playground that Plumix’s own end-to-end tests sign in through. Its theme writes createElement, but a theme of yours can use JSX. The sign-in method these samples use is the email-code method that Writing a Sign-in Method builds. It mails a six-digit code (authClient.emailCode.sendCode) and trades the code for a session (authClient.signIn.emailCode).

A sign-in page and an account page are per-visitor, so register each as a view. App pages with registerView covers views. A plugin in the site registers the three pages this guide builds:

sign-in/pages.ts
import { definePlugin } from "plumix/plugin";
/**
* The sign-in samples' pages. Each is a view, so it differs per visitor and
* the CDN never stores it.
*/
export const signInPages = definePlugin("core-playground-sign-in", {
setup: (setup) => {
setup.registerView("sign-in", {
routes: ["/sign-in"],
resolve: () => ({ data: null, title: "Sign in" }),
});
setup.registerView("sign-in-no-script", {
routes: ["/sign-in-no-script"],
resolve: () => ({ data: null, title: "Sign in" }),
});
setup.registerView("account", {
routes: ["/account"],
resolve: () => ({ data: null, title: "Your account" }),
});
},
});

The theme gives each view a template with forView, and spreads these rules into its templates:

sign-in/templates.ts
import type { ReactNode } from "react";
import { createElement as h } from "react";
import { defineTemplate, forView } from "plumix/theme";
// Extensionless: jiti, which loads the config, maps `.js` to `.ts` only.
import { AccountMenu } from "./account-menu";
import { PlainSignIn } from "./plain-sign-in";
import { SignInForm } from "./sign-in-form";
export const signInTemplates = [
forView("sign-in").template((): ReactNode =>
h("main", null, h(SignInForm, { client: "load" })),
),
forView("sign-in-no-script").template(
defineTemplate({
render: ({ ctx }): ReactNode =>
h("main", null, h(PlainSignIn, { request: ctx.request })),
}),
),
forView("account").template((): ReactNode =>
h("main", null, h(AccountMenu, { client: "load" })),
),
];

The rest of this page is the three components the templates render.

The island below is a two-step form. The first submit mails a code, and the second trades the code for a session:

sign-in/sign-in-form.tsx
"use client";
import type { AuthClientError } from "plumix/auth/client";
import type { IslandProps } from "plumix/blocks";
import type { ReactNode, SyntheticEvent } from "react";
import { useState } from "react";
import { authClient } from "plumix/auth/client";
import { useIsLive } from "plumix/blocks/renderer";
import { SIGN_IN_ERRORS } from "./messages.js";
export function SignInForm(_props: IslandProps<object>): ReactNode {
const live = useIsLive();
const { methods } = authClient.useAuthMethods();
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [sent, setSent] = useState(false);
const [error, setError] = useState<AuthClientError | null>(null);
const passkeys = methods.some((method) => method.name === "passkey");
async function submit(event: SyntheticEvent) {
event.preventDefault();
if (!sent) {
const sending = await authClient.emailCode.sendCode({ email });
setError(sending.error);
setSent(sending.error === null);
return;
}
// Navigates to /account once signed in; only a failure comes back.
const signingIn = await authClient.signIn.emailCode({
email,
code,
redirectTo: "/account",
});
setError(signingIn.error);
}
async function signInWithPasskey() {
const signingIn = await authClient.signIn.passkey({
redirectTo: "/account",
});
setError(signingIn.error);
}
return (
<form
data-testid="sign-in-form"
data-live={live ? "" : undefined}
onSubmit={(event) => void submit(event)}
>
{error ? (
<p role="alert" data-testid="sign-in-error" data-code={error.code}>
{SIGN_IN_ERRORS[error.code] ?? error.message.message}
</p>
) : null}
<input
type="email"
required
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
data-testid="sign-in-email"
/>
{sent ? (
<input
inputMode="numeric"
autoComplete="one-time-code"
required
value={code}
onChange={(event) => setCode(event.target.value)}
data-testid="sign-in-code"
/>
) : null}
<button
type="submit"
data-testid={sent ? "sign-in-submit" : "sign-in-send-code"}
>
{sent ? "Sign in" : "Email me a code"}
</button>
{passkeys ? (
<button type="button" onClick={() => void signInWithPasskey()}>
Use a passkey
</button>
) : null}
</form>
);
}
  • useAuthMethods() decides what to offer. The passkey button shows only when the site lists passkey. A method declared listed: false is left out, and so is a core method the site didn’t configure, such as magicLink without its auth() block. The email-code method is unlisted, because the admin’s login screen offers every listed method as a button that calls signIn() with no arguments. So the form offers it directly.
  • { data, error } drives the state. A call that failed puts its error on the page. The form looks up its own copy by error.code, and falls back to the kernel’s English message.message for a code it has no copy for.
  • A successful sign-in navigates. The call passed redirectTo: "/account", so once the session exists the browser goes to /account. Leave redirectTo out and the page stays where it is, while every useSession() on it turns signed-in.

A method’s call runs only once its browser half is mounted. Plumix’s Vite plugin mounts every installed method’s half before an island renders, so in an island this always holds. A call to a method no plugin registered resolves error.code method_not_registered.

useSession() reads the session the page was rendered with, so a signed-in visitor sees their address on the first paint, before the island hydrates:

sign-in/account-menu.tsx
"use client";
import type { IslandProps } from "plumix/blocks";
import type { ReactNode } from "react";
import { useRef, useState } from "react";
import { authClient } from "plumix/auth/client";
export function AccountMenu(_props: IslandProps<object>): ReactNode {
const { user, isPending } = authClient.useSession();
const canEditOwn = authClient.useCan("user:edit_own");
if (isPending) return null;
const permission = (
<span data-testid="account-can-edit-own">{String(canEditOwn)}</span>
);
if (user === null) {
return (
<div>
{permission}
<a href="/sign-in" data-testid="account-signed-out">
Sign in
</a>
</div>
);
}
return (
<div>
{permission}
<span data-testid="account-email">{user.email}</span>
<span data-testid="account-name">{user.name}</span>
<ProfileForm />
<button
type="button"
onClick={() => void authClient.signOut()}
data-testid="account-sign-out"
>
Sign out
</button>
</div>
);
}
function ProfileForm(): ReactNode {
const { account } = authClient.useAccount();
const input = useRef<HTMLInputElement>(null);
const [error, setError] = useState("");
if (account === null) return null;
async function save(): Promise<void> {
const name = input.current?.value ?? "";
const result = await authClient.account.update({ name });
setError(result.error?.code ?? "");
}
return (
<form
data-testid="account-profile"
onSubmit={(event) => {
event.preventDefault();
void save();
}}
>
<input
ref={input}
name="name"
defaultValue={account.name ?? ""}
data-testid="account-name-input"
/>
<button type="submit" data-testid="account-save">
Save
</button>
<span data-testid="account-error">{error}</span>
</form>
);
}

isPending is true only while the session is still unknown, such as on a page no server render produced. signOut() with no redirectTo leaves the visitor on the page, and the menu turns to its signed-out branch.

The menu’s form renames the visitor with account.update. It always acts on the session’s user, so it takes no id, and it carries the concurrency token the account was loaded with, so the theme never handles one. A save that worked refreshes the session, so the name useSession() shows changes with no reload. When the account was saved somewhere else first, such as in another tab, the call resolves error.code etag_mismatch after reloading the account, and the next save goes through. A save also wins over an admin who has the user open in the admin. Other refusals resolve unauthorized when signed out, forbidden for a meta key the user may not edit, and invalid_input for a value the server refuses. signOut() clears the account.

sessions.list() resolves each of the user’s sessions with its id, ipAddress, userAgent, createdAt and expiresAt as Dates, and current: true on the one this page runs in. sessions.revoke(id) ends another session and sessions.revokeOthers() ends every session but this one, resolving { revoked }. Neither ends the current session: revoke with its id resolves error.code current_session, so call signOut() instead. A user signed in by an identity provider such as Cloudflare Access has no current session, so revokeOthers() ends all of theirs. They are plain promises with no cache, so list again after a change.

passkey.list() resolves the signed-in user’s passkeys, oldest first, each with its id, its name (null until the user names one), isBackedUp, transports, and createdAt and lastUsedAt as ISO 8601 strings. It keeps no cache, so call it again after register, rename or remove. rename(id, name) and remove(id) act only on the session’s user: another user’s passkey id resolves error.code not_found. remove of the user’s only passkey resolves last_credential and keeps it, so a user who signs in only with passkeys can’t lock themselves out. On a runtime that refuses the passkeys area, such as the Cloudflare demo, each call resolves forbidden.

useCan is a hint for showing or hiding a control, not a permission: the server decides every write, and refuses one the user may not make whatever the island showed. It takes a capability, never a record. Whether a user may edit this entry depends on its author and status, so decide it on the server with ctx.auth.can in a loader or template, and pass the answer to the island.

signOut({ redirectTo: "/" }) navigates instead. Either way it resolves data.redirectTo: the identity provider’s logout URL when the site’s authenticator has one, else the redirectTo you passed, else null. A provider’s logout URL wins over yours, and it must be followed or the visitor is signed straight back in, so call signOut with a redirectTo on such a site.

authClient.url() builds a form’s action during the server render, base path included, so the form below signs someone in with JavaScript off. It is a server component, not an island:

sign-in/plain-sign-in.tsx
import type { ReactNode } from "react";
import { authClient } from "plumix/auth/client";
import { SIGN_IN_ERRORS } from "./messages.js";
export function PlainSignIn({
request,
}: {
readonly request: Request;
}): ReactNode {
const query = new URL(request.url).searchParams;
const error = query.get("email_code_error");
const send = authClient.url({ method: "email-code", route: "request" });
const verify = authClient.url(
{ method: "email-code", route: "verify" },
{ redirectTo: "/account" },
);
return (
<form method="post" action={verify}>
{query.has("email_code_sent") ? (
<p data-testid="plain-sign-in-sent">Check your inbox for a code.</p>
) : null}
{error ? (
<p role="alert" data-testid="plain-sign-in-error" data-code={error}>
{SIGN_IN_ERRORS[error] ?? "Something went wrong. Try again."}
</p>
) : null}
<input
name="email"
type="email"
required
autoComplete="email"
data-testid="plain-sign-in-email"
/>
<button
type="submit"
formAction={send}
data-testid="plain-sign-in-send-code"
>
Email me a code
</button>
<input
name="code"
inputMode="numeric"
autoComplete="one-time-code"
data-testid="plain-sign-in-code"
/>
<button type="submit" data-testid="plain-sign-in-submit">
Sign in
</button>
</form>
);
}
  • The target is { method, route } for one of a method’s routes, or "signOut" for sign-out. A form on a page only a signed-in visitor sees signs out with action={authClient.url("signOut", { redirectTo: "/" })}.
  • redirectTo goes on the URL’s query. A route that proves an identity reads it from there, so a successful sign-in lands on /account.
  • The route must accept a plain form. A form can’t send the X-Plumix-Request header that every POST to an auth route needs, so a route takes a plain form only when its method declares it formPost. The email-code method declares both of its routes that way. Core’s magic-link request and sign-out accept one too. A form post must still come from the site’s own origin. A method’s formPost route runs it with no signed-in visitor, so a cross-site form can’t act as whoever is signed in. Sign-out is the exception: it keeps the visitor’s session, because ending that session is its job.
  • Two buttons, one form. The first button posts to the request route through formAction, and the second posts to verify through the form’s own action.

A form post is answered with a 303 redirect. A sign-in that worked lands on the redirectTo. Anything else lands on the site’s loginPath, which the next section covers.

Pass redirectTo app-relative, as /account. authClient adds the base path before the value goes on the wire, so the same theme works on a site mounted under /blog.

The server keeps a redirectTo only when it is a root-relative path inside the base path, with no . or .. segment, no backslash and no control character. Anything else, such as https://evil.example or //evil.example, sends the visitor to the site root instead. A sign-in with no redirectTo at all is sent to the admin.

A fetch sign-in answers JSON with the checked redirectTo. authClient navigates to it only when your call passed one. A navigation or a form post is answered with a redirect to it.

A navigation or a form post can’t be answered with JSON, so a method answers it with a redirect to the site’s loginPath, with a query parameter naming the outcome. A failure adds <key>_error=<code>, and a notice adds <key>_<name>=1. A method’s key is its name with - replaced by _. So the email-code method lands a wrong code on ?email_code_error=wrong_code and a sent code on ?email_code_sent=1.

loginPath defaults to the admin’s login screen. Point it at the theme’s page in auth(), as Configuration describes, so the outcomes land where your form reads them. The playground points it at /sign-in-no-script.

The page reads the parameter off the request and shows its own copy. The samples above share one table of it:

sign-in/messages.ts
/**
* The theme's own copy for each code the email-code method can answer, and
* for the identity refusals core adds.
*/
export const SIGN_IN_ERRORS: Readonly<Record<string, string>> = {
invalid_input: "Enter your email address and the code we sent.",
too_many_codes: "We've sent several codes already. Wait a few minutes.",
code_expired: "That code has expired. Send yourself a new one.",
wrong_code: "That code isn't right. Check the email and try again.",
too_many_attempts: "Too many wrong codes. Send yourself a new one.",
domain_not_allowed: "This site doesn't take sign-ups from that address.",
registration_closed: "This site isn't taking new sign-ups.",
account_disabled: "This account has been disabled.",
};

Core adds codes of its own to every method’s. When the identity a method proved isn’t allowed an account, the answer is one of email_unverified, account_disabled, domain_not_allowed, registration_closed or link_broken. domain_not_allowed is what a new address gets when the site has no open selfSignup and the address’s domain isn’t one of its allowed domains. An existing user signs in whatever their domain. A fetch gets the same codes in error.code.

Access Overview covers how a request resolves to the principal useSession() reports. Passkeys covers the ceremony behind signIn.passkey() and passkey.register(). Writing a Sign-in Method builds the email-code method these samples call, both its server and its browser half.

Read Writing a Sign-in Method to add a method of your own to authClient.signIn.