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.
Overview
Section titled “Overview”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,magicLinkandoauthfrom core, and each installed plugin’s method under the camelCase of its name. A method can add more actions under its own name, such asauthClient.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.usercarriesid,email,name,avatarUrl,roleandcapabilities, the capabilities the user’s role grants.authClient.useCan(capability), which returns whether the session’s capabilities includecapability, such asentry:post:create. It readsuseSession(), so the server render and hydration agree, and it turnsfalseon sign-out.authClient.can(capability)is the same check outside a component. Both arefalsewhile the session is pending or signed out.authClient.useAuthMethods(), which returns the sign-in methods the site lists, each with itsnameandlabel, and the configured OAuth providers.authClient.useAccount(), which returns{ account, isPending }: the signed-in user’s ownid,name,avatarUrlandmeta, ornullwhen 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 resolvesdata.locale, the enabled locale’s code. A code the site hasn’t enabled resolveserror.codelocale_not_supportedand 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 besidepasskey.register().authClient.oauth.accounts(), which resolves the signed-in user’s linked OAuth accounts, each with itsid,providerandlinkedAtas aDate. It keeps no cache, so call it again after a change.authClient.oauth.unlink(provider), which removes the signed-in user’s link toproviderand 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 resolveserror.codelast_sign_in_methodand the link stays. A provider the user hasn’t linked resolvesnot_found. On a runtime that refuses theoauthLinkingarea, such as the Cloudflare demo, both calls resolveforbidden.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).
Quickstart
Section titled “Quickstart”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:
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:
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.
A sign-in page in an island
Section titled “A sign-in page in an island”The island below is a two-step form. The first submit mails a code, and the second trades the code for a session:
"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 listspasskey. A method declaredlisted: falseis left out, and so is a core method the site didn’t configure, such asmagicLinkwithout itsauth()block. The email-code method is unlisted, because the admin’s login screen offers every listed method as a button that callssignIn()with no arguments. So the form offers it directly.{ data, error }drives the state. A call that failed puts itserroron the page. The form looks up its own copy byerror.code, and falls back to the kernel’s Englishmessage.messagefor 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. LeaveredirectToout and the page stays where it is, while everyuseSession()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.
An account menu
Section titled “An account menu”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:
"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.
A form that works with no script
Section titled “A form that works with no script”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:
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 withaction={authClient.url("signOut", { redirectTo: "/" })}. redirectTogoes 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-Requestheader that everyPOSTto an auth route needs, so a route takes a plain form only when its method declares itformPost. 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’sformPostroute 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
requestroute throughformAction, and the second posts toverifythrough the form’s ownaction.
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.
The redirectTo rules
Section titled “The redirectTo rules”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.
Outcomes on loginPath
Section titled “Outcomes on loginPath”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:
/** * 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.
Related
Section titled “Related”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.
Next steps
Section titled “Next steps”Read Writing a Sign-in Method to add a method of your own to authClient.signIn.