Custom sign-in page
Draw your own sign-in screen, deep-link into Runlot ID, handle errors, brand the Runlot page, and test it locally.
Your app can own the sign-in experience before and after the Runlot screen, and decide what the Runlot screen looks like. The Runlot ID password is still typed only on a Runlot page: anything you draw yourself either links to Runlot or uses a project-local method.
Deep links into Runlot ID
/__runlot/auth/sign-in/runlot takes four optional parameters. Without them it behaves as before.
| Parameter | Effect |
|---|---|
idp | google or github skips the chooser and goes straight to that provider. password opens the email form, passkey the passkey prompt. A method that is turned off is ignored and the chooser is shown. |
login_hint | Prefills the email on the Runlot form. It is never submitted automatically and nothing about the account comes back to your app. |
error_next | Where errors return to instead of next. A same-origin path, like next. |
prompt | none signs the person in without any page when they are already signed in to Runlot and have used your app before; otherwise it returns login_required. |
import { signInUrl } from "@runlot/auth/client";
signInUrl("runlot", { idp: "google", next: "/chat" });
// /__runlot/auth/sign-in/runlot?next=%2Fchat&idp=google
signInUrl("runlot", { idp: "password", loginHint: "[email protected]", errorNext: "/login" });idp, loginHint, errorNext and prompt are accepted only for "runlot" — passing them with another provider is a type error. config() tells you which methods are on: the runlot provider entry carries methods, for example ["password", "google", "github"].
Errors: auth_error
When sign-in does not succeed, the person comes back to error_next (or next) with two query parameters:
/login?auth_error=cancelled&auth_request=6f1c…| Code | Meaning |
|---|---|
cancelled | The person went back or closed the provider screen |
signup_closed | Sign-ups are off and there is no existing account |
email_unverified | Verified email is required and the provider's email is not verified |
provider_unavailable | The method was turned off during sign-in |
rate_limited | Too many attempts |
expired | The sign-in request is too old |
consent_denied | The person declined |
login_required | prompt=none and no usable session |
account_exists | Link by email is off and the email belongs to another account |
server_error | Anything else; auth_request is what support looks up |
The code never carries an email, a provider message or a stack. @runlot/auth exports the closed list and a default message in Korean, English and Japanese:
import { authErrorMessage, parseAuthError, stripAuthError } from "@runlot/auth/client";
const err = parseAuthError(location.href); // { code, request } | null
if (err) {
show(authErrorMessage(err.code, "ko"));
stripAuthError(); // removes auth_error/auth_request so a reload does not show it again
}An unknown code is reported as server_error.
<runlot-sign-in>
A dependency-free web component that draws the enabled Runlot methods as links, shows an auth_error message and then strips it from the address bar. It is about 4 KiB gzipped.
<script type="module">import "@runlot/auth/ui";</script>
<runlot-sign-in next="/chat" lang="ko" methods="google github password" guest error-next="/login"></runlot-sign-in>| Attribute | |
|---|---|
next, error-next, login-hint | Passed to every link |
lang | ko (default), en or ja |
methods | Which buttons to draw, space-separated; runlot is the plain chooser button. Defaults to what the project offers, and a method the project turned off is never drawn |
guest | Adds a guest button that fires a runlot-guest event |
Every element has a part, so you can style it from outside: runlot-sign-in::part(button-google), ::part(error), ::part(root). Colours, radius and font come from CSS custom properties: --runlot-brand, --runlot-brand-text, --runlot-bg, --runlot-border, --runlot-radius, --runlot-font, --runlot-gap, --runlot-error. The header and footer slots take your own markup.
React:
import { RunlotSignIn } from "@runlot/auth/react";
<RunlotSignIn next="/chat" lang="ko" methods={["google", "github"]} guest onGuest={() => enterAsGuest()} />JSON API for project-local methods
If you want the form inside your own page, use the project's own password and email-link methods through JSON. These accounts belong to your project only; they are not Runlot ID accounts.
import { signInWithPassword, signUp, requestReset, confirmReset, sendMagicLink } from "@runlot/auth/client";
const r = await signInWithPassword({ email, password });
if (r.ok) location.href = "/";
else show(authErrorMessage(r.error, "ko")); // invalid_credentials, rate_limited, …| Route | Body |
|---|---|
POST /__runlot/auth/api/password/sign-in | {email, password} → 200 {user} and the session cookie |
POST /__runlot/auth/api/password/sign-up | {email, password, name?} |
POST /__runlot/auth/api/password/reset | {email} → always 202 |
POST /__runlot/auth/api/password/reset/confirm | {token, password} |
POST /__runlot/auth/api/magic-link | {email, next} → always 202 |
Requests must be Content-Type: application/json (otherwise 415) and come from your app's origin (otherwise 403 csrf). Errors are the codes above plus invalid_credentials, weak_password and check_email. A 429 carries Retry-After.
Branding the Runlot page
The Runlot chooser can carry your copy, layout and colours. All fields are optional; the defaults reproduce the plain page. Branding is data, validated by the server — there is no custom HTML or CSS.
runlot auth set brand-copy ko.title "세법 원문을 근거로 답합니다"
runlot auth set brand-layout split # centered | split | minimal
runlot auth set brand-theme dark # light | dark | system
runlot auth set brand-button pill # rounded | pill | square
runlot auth set brand-background color "#0b1020" # none | color | gradient | image
runlot auth set brand-logo-dark https://example.com/logo-dark.svg
runlot auth set brand-link privacy https://example.com/privacy
runlot auth set brand-lang ko # ko | en | ja | auto
runlot auth set brand-methods google,github # all = every available methodCopy is limited to 80 characters for the title and 200 for the subtitle and the footer, per language. Links and images must be https URLs.
To manage the whole object as a file:
runlot auth brand get > branding.json
runlot auth brand apply branding.jsonAn invalid file is refused with 400 invalid_branding and the path of each bad field. The dashboard's Sign-in & access tab has the same form with a live preview.
Preview
runlot auth preview # open the Runlot page with your branding
runlot auth preview --lang en --theme dark --state error:cancelled--state is password, passkey or error:<code>. The preview needs no real sign-in.
Local development: runlot dev --auth=chooser
runlot dev --auth=chooser/__runlot/auth/sign-in/runlot then answers with a local copy of the chooser in your branding. Pick a test identity to come back signed in, or press an error button to come back with any auth_error code. idp, login_hint, error_next and prompt behave as in production. The branding is read once from the API and cached in .runlot/auth-branding.json; delete that file to refresh it. Offline, the default look is used. Without the flag, runlot dev keeps signing in the fixed dev user.
Linking accounts and merging users
By default a Runlot sign-in whose verified email matches an existing user under another method is attached to that user. To keep them apart:
runlot auth set link-by-email offSuch a sign-in then returns auth_error=account_exists.
To join two users after the fact, for example after switching from your own Google app to Runlot:
runlot auth users merge <from-user-id> <into-user-id>This moves every identity of <from> to <into>, signs <from> out and deletes it, in one transaction in the project database. Rows in your own tables that reference <from> are yours to update. From a worker, env.auth.users.merge(from, into) does the same.
The dashboard's sign-in tab also shows a 30-day funnel — starts, successes and each error code per day — and runlot auth funnel --days 30 prints the totals. No per-person data is kept.