Skip to content

Sign in and account

Centered and split layouts for sign in, sign up, verification and account states.

What it's for#

Everything before someone is inside a workspace: logging in, signing up, proving it's them, and the account states that stop them at the door. These pages sit outside the shell, with no sidebar and no app header.

There are two layouts. Log in and sign up are split: the form on the left, a sample of the product on the right from 1024px. Everything else is centered: one narrow column on the plane, with no card around it. Both keep the same 24rem form column, the wordmark top left and a quiet footer, so moving from log in to verification feels like one flow.

Routes built on the auth template
RouteLayoutWhat it asks
/loginSplitProviders, a passkey, or email and password.
/signupSplitName, work email, password and the terms.
/verify-otpCenteredThe 6-digit code we emailed.
/verifyCenteredTwo-factor: an authenticator code, a text or a backup code.
/pausedCenteredA workspace paused for billing, outside any workspace.
/suspendedCenteredYour membership in a workspace is suspended.

Live composition#

The two layouts at 1280px, built from Canon fields and buttons.

Split: log in

Try logging in with an empty form to see field errors, or pick a provider to see the pending state.

Oration

Log in to Oration

or

New to Oration?

  • SOC 2
  • ISO 27001
  • GDPR
import { Field, FieldError, FieldGroup, FieldLabel, FieldSeparator } from "@oration/canon/components/field";import { Input } from "@oration/canon/components/input";import { MonogramTile } from "@oration/canon/components/monogram-tile";import { PasswordInput } from "@oration/canon/components/password-input";import { PendingButton } from "@oration/canon/components/pending-button";import { toast } from "@oration/canon/components/toast";import { FingerprintIcon } from "lucide-react";import * as React from "react";import { AuthHeading, AuthSplit, ProductPanel } from "./chrome";export function LogIn() {    const [email, setEmail] = React.useState("");    const [password, setPassword] = React.useState("");    const [errors, setErrors] = React.useState<{        email?: string;        password?: string;    }>({});    const [pending, setPending] = React.useState<string | null>(null);    const linkClass =        "font-medium text-foreground underline-offset-4 hover:underline";    const mutedLinkClass =        "text-muted-foreground transition-colors duration-150 hover:text-foreground";    const continueWith = (label: string) => {        setPending(label);        window.setTimeout(() => {            setPending(null);            toast.add({                title: `Continuing with ${label}`,                description: "Opening Cedarline.",            });        }, 900);    };    return (        <AuthSplit            panel={                <ProductPanel                    caller="Jordan Lee"                    company="Northwind Freight"                    lines={[                        {                            speaker: "Jordan Lee",                            text: "I'm calling about invoice NF-4471. It says on hold.",                        },                        {                            speaker: "Nora",                            text: "It's waiting on a matching PO. I can see PO 88120 on your account. Should I attach it?",                        },                        {                            speaker: "Jordan Lee",                            text: "Yes, please. That's the right one.",                        },                    ]}                    outcome="Resolved without a transfer. NF-4471 joins Friday's payment run."                />            }        >            <AuthHeading title="Log in to Oration" />            <div className="mt-8 flex flex-col gap-2">                {[                    ["Google", "blue"],                    ["Microsoft", "teal"],                ].map(([name, color]) => (                    <PendingButton                        key={name}                        variant="outline"                        size="lg"                        className="w-full"                        pending={pending === name}                        onClick={() => continueWith(String(name))}                    >                        <MonogramTile                            name={String(name)}                            color={color === "blue" ? "blue" : "teal"}                            size="sm"                        />                        Continue with {name}                    </PendingButton>                ))}                <PendingButton                    variant="outline"                    size="lg"                    className="w-full"                    pending={pending === "a passkey"}                    onClick={() => continueWith("a passkey")}                >                    <FingerprintIcon                        data-icon="inline-start"                        aria-hidden="true"                    />                    Log in with a passkey                </PendingButton>            </div>            <FieldSeparator className="my-6">or</FieldSeparator>            <form                noValidate                onSubmit={(event) => {                    event.preventDefault();                    const next = {                        email: email.includes("@")                            ? undefined                            : "Enter the email you log in with.",                        password: password ? undefined : "Enter your password.",                    };                    setErrors(next);                    if (next.email || next.password) return;                    continueWith("your password");                }}            >                <FieldGroup className="gap-4">                    <Field data-invalid={errors.email ? true : undefined}>                        <FieldLabel htmlFor="login-email">Email</FieldLabel>                        <Input                            id="login-email"                            type="email"                            autoComplete="username webauthn"                            placeholder="name@company.com"                            className="h-9"                            value={email}                            aria-invalid={errors.email ? true : undefined}                            aria-describedby={                                errors.email ? "login-email-error" : undefined                            }                            onChange={(event) => setEmail(event.target.value)}                        />                        <FieldError id="login-email-error">                            {errors.email}                        </FieldError>                    </Field>                    <Field                        data-invalid={errors.password ? true : undefined}                        className="relative"                    >                        <FieldLabel htmlFor="login-password">                            Password                        </FieldLabel>                        <PasswordInput                            id="login-password"                            autoComplete="current-password"                            value={password}                            aria-invalid={errors.password ? true : undefined}                            aria-describedby={                                errors.password                                    ? "login-password-error"                                    : undefined                            }                            onChange={(event) =>                                setPassword(event.target.value)                            }                        />                        <FieldError id="login-password-error">                            {errors.password}                        </FieldError>                        <button                            type="button"                            onClick={() =>                                toast.add({                                    title: "Forgot password?",                                    description:                                        "We emailed a reset link to the address above.",                                })                            }                            className={`absolute top-0 right-0 w-auto! text-13 leading-snug ${mutedLinkClass}`}                        >                            Forgot password?                        </button>                    </Field>                    <PendingButton                        type="submit"                        size="lg"                        className="mt-1 w-full"                        pending={pending === "your password"}                    >                        Log in                    </PendingButton>                </FieldGroup>            </form>            <p className="mt-4 text-center text-sm">                <button                    type="button"                    onClick={() =>                        toast.add({                            title: "Use single sign-on",                            description:                                "Enter your work email to find your organization.",                        })                    }                    className={mutedLinkClass}                >                    Use single sign-on                </button>            </p>            <p className="mt-10 text-center text-sm text-muted-foreground">                New to Oration?{" "}                <button                    type="button"                    onClick={() =>                        toast.add({                            title: "Create an account",                            description: "Sign up opens in the split layout.",                        })                    }                    className={linkClass}                >                    Create an account                </button>            </p>        </AuthSplit>    );}

Centered: verify email

Type 000000 for a wrong code, 111111 for an expired one, or any other six digits to verify. The resend button counts down from 0:30.

Oration

Enter the code we emailed you

We sent a 6-digit code to maya@cedarline.com. It expires in 10 minutes.

Didn't get the code?
Wrong address?
import { Button } from "@oration/canon/components/button";import { Field, FieldError, FieldLabel } from "@oration/canon/components/field";import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "@oration/canon/components/input-otp";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import { CheckIcon, MailIcon, TimerOffIcon } from "lucide-react";import * as React from "react";import { AuthCentered, AuthHeading, AuthIconTile } from "./chrome";export function VerifyEmail() {    const [code, setCode] = React.useState("");    const [error, setError] = React.useState<string | null>(null);    const [expired, setExpired] = React.useState(false);    const [verified, setVerified] = React.useState(false);    const [cooldown, setCooldown] = React.useState(30);    React.useEffect(() => {        if (cooldown <= 0) return;        const id = window.setTimeout(() => setCooldown(cooldown - 1), 1000);        return () => window.clearTimeout(id);    }, [cooldown]);    const verify = (value: string) => {        if (value.length < 6)            return setError("Enter all 6 digits of the code.");        if (value === "000000")            return setError(                "That code isn't right. Check the latest email and try again.",            );        if (value === "111111") return setExpired(true);        setError(null);        setVerified(true);        toast.add({            type: "success",            title: "Email verified",            description: "Opening workspace setup.",        });    };    return (        <AuthCentered>            <AuthHeading                media={                    <AuthIconTile tone={verified ? "success" : "neutral"}>                        {verified ? <CheckIcon /> : <MailIcon />}                    </AuthIconTile>                }                title="Enter the code we emailed you"                description={                    <>                        We sent a 6-digit code to{" "}                        <span className="font-medium text-foreground">                            maya@cedarline.com                        </span>                        . It expires in 10 minutes.                    </>                }            />            <form                noValidate                className="mt-8 flex flex-col gap-5"                onSubmit={(event) => {                    event.preventDefault();                    verify(code);                }}            >                {expired ? (                    <p className="flex items-start gap-3 rounded-lg bg-muted/70 px-3 py-2.5 text-13 text-muted-foreground">                        <TimerOffIcon                            aria-hidden="true"                            className="mt-0.5 size-4 shrink-0"                        />                        That code expired. Codes work for 10 minutes.                    </p>                ) : null}                <Field data-invalid={error ? true : undefined}>                    <FieldLabel htmlFor="verify-code" className="sr-only">                        Code from email                    </FieldLabel>                    <InputOTP                        id="verify-code"                        maxLength={6}                        pattern="^\d+$"                        inputMode="numeric"                        autoComplete="one-time-code"                        value={code}                        onChange={(value) => {                            setCode(value);                            if (error) setError(null);                        }}                        onComplete={verify}                        aria-invalid={error ? true : undefined}                        aria-describedby={                            error ? "verify-code-error" : undefined                        }                        containerClassName="w-full gap-2"                    >                        {[0, 3].map((start) => (                            <React.Fragment key={start}>                                {start > 0 ? (                                    <InputOTPSeparator className="text-subtle-foreground" />                                ) : null}                                <InputOTPGroup className="flex-1">                                    {[start, start + 1, start + 2].map(                                        (index) => (                                            <InputOTPSlot                                                key={index}                                                index={index}                                                aria-invalid={                                                    error ? true : undefined                                                }                                                className={cn(                                                    "h-12 flex-1 text-lg font-medium tabular-nums",                                                    error &&                                                        "border-destructive data-[active=true]:border-destructive data-[active=true]:ring-destructive/20",                                                    verified &&                                                        "border-success/60",                                                )}                                            />                                        ),                                    )}                                </InputOTPGroup>                            </React.Fragment>                        ))}                    </InputOTP>                    <FieldError id="verify-code-error">{error}</FieldError>                </Field>                <Button type="submit" size="lg" className="w-full">                    {verified ? (                        <CheckIcon                            data-icon="inline-start"                            aria-hidden="true"                        />                    ) : null}                    {verified                        ? "Verified"                        : expired                          ? "Send a new code"                          : "Verify email"}                </Button>            </form>            <div className="mt-8 flex flex-col gap-2 border-t border-border pt-6 text-sm">                <div className="flex min-h-8 items-center justify-between gap-3">                    <span className="text-muted-foreground">                        Didn't get the code?                    </span>                    <Button                        variant="outline"                        size="sm"                        disabled={cooldown > 0}                        className="disabled:opacity-60"                        onClick={() => {                            setCooldown(30);                            setExpired(false);                            toast.add({                                title: "New code sent",                                description: "Earlier codes no longer work.",                            });                        }}                    >                        Send a new code                        {cooldown > 0 ? (                            <span className="text-muted-foreground tabular-nums">                                0:{String(cooldown).padStart(2, "0")}                            </span>                        ) : null}                    </Button>                </div>                <div className="flex min-h-8 items-center justify-between gap-3">                    <span className="text-muted-foreground">                        Wrong address?                    </span>                    <Button variant="ghost" size="sm">                        Use a different email                    </Button>                </div>            </div>        </AuthCentered>    );}

The wordmark, footer, product panel and trust row in these frames are Canon copies of the product's auth chrome, kept in this page's chrome.tsx. The product's panel plays a recorded call with Nora; here it is a still.

Anatomy#

Nine parts in the split layout. The centered layout keeps one through seven and drops the panel.

  1. Wordmark. Top left in a 64px header, linking to log in. Signed-in status pages add Logged in as and Log out on the right.
  2. Heading. The page's h1 in Headline, an optional 14px description, and on status pages a 40px icon tile or a workspace chip above it.
  3. Providers. Continue with Google, Continue with Microsoft and Log in with a passkey: 36px outline buttons, full width, 8px apart.
  4. Separator. Field's FieldSeparator with or, 24px above and below.
  5. Fields. 36px inputs in a FieldGroup 16px apart, labels above, errors below, Forgot password? top right of the password field.
  6. Primary button. One filled, full-width, 36px Pending button. It keeps its width and shows a spinner while working.
  7. Footer. Terms and Privacy as extra-small ghost buttons and a theme switch, in a 56px row.
  8. Product panel. From 1024px, an 8px inset Cool Rail panel with container corners and a sample supplier call on a card.
  9. Trust row. A shield icon and the plain names SOC 2, ISO 27001 and GDPR, then a Trust center link. Log in and sign up only.

The trust row#

Plain text, no badges or logos, and only where someone is deciding whether to hand over credentials: log in and sign up. Verification and status pages never show it.

  • SOC 2
  • ISO 27001
  • GDPR

Screens#

Sign up, two-factor and the two account states, each on the layout it uses.

Sign up

Type a password to see the strength meter; a gmail.com address gets the work email hint.

Oration

Create your Oration account

Set up a workspace for your team in a few minutes.

or

Already have an account?

  • SOC 2
  • ISO 27001
  • GDPR
import { Button } from "@oration/canon/components/button";import { Checkbox } from "@oration/canon/components/checkbox";import {  Field,  FieldDescription,  FieldError,  FieldGroup,  FieldLabel,  FieldSeparator,} from "@oration/canon/components/field";import { Input } from "@oration/canon/components/input";import { Meter } from "@oration/canon/components/meter";import { MonogramTile } from "@oration/canon/components/monogram-tile";import { PasswordInput } from "@oration/canon/components/password-input";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import { CheckIcon } from "lucide-react";import * as React from "react";import { AuthHeading, AuthSplit, ProductPanel } from "./chrome";export function SignUp() {    const [name, setName] = React.useState("");    const [email, setEmail] = React.useState("");    const [password, setPassword] = React.useState("");    const [terms, setTerms] = React.useState(false);    const [attempted, setAttempted] = React.useState(false);    const checks = [        { label: "8 or more characters", met: password.length >= 8 },        { label: "A number", met: /\d/.test(password) },        { label: "A symbol", met: /[^A-Za-z0-9]/.test(password) },    ];    const score =        checks.filter((check) => check.met).length +        (password.length >= 12 ? 1 : 0);    const free = /@(gmail|yahoo|outlook|hotmail)\./i.test(email);    return (        <AuthSplit            panel={                <ProductPanel                    caller="Wen Zhou"                    company="Orchard Street"                    lines={[                        {                            speaker: "Wen Zhou",                            text: "We're a new supplier. What do you need from us before the first payment?",                        },                        {                            speaker: "Nora",                            text: "A W-9 and your bank details. I'll text you a secure link for both.",                        },                    ]}                    outcome="W-9 requested. Wen gets a secure link by text."                />            }        >            <AuthHeading                title="Create your Oration account"                description="Set up a workspace for your team in a few minutes."            />            <div className="mt-8 flex flex-col gap-2">                <Button variant="outline" size="lg" className="w-full">                    <MonogramTile name="Google" color="blue" size="sm" />                    Sign up with Google                </Button>                <Button variant="outline" size="lg" className="w-full">                    <MonogramTile name="Microsoft" color="teal" size="sm" />                    Sign up with Microsoft                </Button>            </div>            <FieldSeparator className="my-6">or</FieldSeparator>            <form                noValidate                onSubmit={(event) => {                    event.preventDefault();                    setAttempted(true);                    if (!name || !email || score < 3 || !terms) return;                    toast.add({                        type: "success",                        title: "Account created",                        description: `We sent a 6-digit code to ${email}.`,                    });                }}            >                <FieldGroup className="gap-4">                    <Field data-invalid={attempted && !name ? true : undefined}>                        <FieldLabel htmlFor="signup-name">Full name</FieldLabel>                        <Input                            id="signup-name"                            autoComplete="name"                            placeholder="Maya Okafor"                            className="h-9"                            value={name}                            onChange={(event) => setName(event.target.value)}                        />                        <FieldError>                            {attempted && !name                                ? "Enter your full name."                                : null}                        </FieldError>                    </Field>                    <Field>                        <FieldLabel htmlFor="signup-email">                            Work email                        </FieldLabel>                        <Input                            id="signup-email"                            type="email"                            autoComplete="email"                            placeholder="name@company.com"                            className="h-9"                            value={email}                            onChange={(event) => setEmail(event.target.value)}                        />                        {free ? (                            <FieldDescription className="text-13">                                Use your work email so teammates can find your                                workspace.                            </FieldDescription>                        ) : null}                    </Field>                    <Field                        data-invalid={attempted && score < 3 ? true : undefined}                    >                        <FieldLabel htmlFor="signup-password">                            Password                        </FieldLabel>                        <PasswordInput                            id="signup-password"                            autoComplete="new-password"                            value={password}                            onChange={(event) =>                                setPassword(event.target.value)                            }                        />                        {password ? (                            <div className="flex flex-col gap-2">                                <div className="flex items-center gap-3">                                    <Meter                                        className="flex-1"                                        size="sm"                                        max={4}                                        value={score}                                        label="Password strength"                                        tone={                                            score >= 3                                                ? "success"                                                : score === 2                                                  ? "warning"                                                  : "danger"                                        }                                    />                                    <span className="w-12 text-xs text-muted-foreground">                                        {                                            [                                                "Weak",                                                "Weak",                                                "Fair",                                                "Good",                                                "Strong",                                            ][score]                                        }                                    </span>                                </div>                                <ul className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">                                    {checks.map((check) => (                                        <li                                            key={check.label}                                            className={cn(                                                "flex items-center gap-1",                                                check.met && "text-foreground",                                            )}                                        >                                            <CheckIcon                                                aria-hidden="true"                                                className={cn(                                                    "size-3",                                                    !check.met && "opacity-30",                                                )}                                            />                                            {check.label}                                        </li>                                    ))}                                </ul>                            </div>                        ) : null}                    </Field>                    <Field                        data-invalid={attempted && !terms ? true : undefined}                        className="gap-1.5"                    >                        <FieldLabel className="items-start font-normal text-foreground">                            <Checkbox                                className="mt-0.5"                                checked={terms}                                onCheckedChange={setTerms}                            />                            I agree to the Oration terms of service and privacy                            policy                        </FieldLabel>                        <FieldError>                            {attempted && !terms                                ? "Accept the terms of service and privacy policy to continue."                                : null}                        </FieldError>                    </Field>                    <Button type="submit" size="lg" className="mt-1 w-full">                        Create account                    </Button>                </FieldGroup>            </form>            <p className="mt-10 text-center text-sm text-muted-foreground">                Already have an account?{" "}                <button                    type="button"                    onClick={() =>                        toast.add({                            title: "Log in",                            description: "Log in opens in the split layout.",                        })                    }                    className="font-medium text-foreground underline-offset-4 hover:underline"                >                    Log in                </button>            </p>        </AuthSplit>    );}

Two-factor

An authenticator code by default, with a backup code and a text as alternatives. 000000 fails.

Oration

Enter your verification code

Open your authenticator app and enter the 6-digit code for Oration.

Can't use any of these? Ask a Cedarline admin to reset your two-factor.

import { Button } from "@oration/canon/components/button";import { Checkbox } from "@oration/canon/components/checkbox";import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@oration/canon/components/field";import { Input } from "@oration/canon/components/input";import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "@oration/canon/components/input-otp";import { toast } from "@oration/canon/components/toast";import { KeyRoundIcon } from "lucide-react";import * as React from "react";import { AuthCentered, AuthHeading, AuthIconTile } from "./chrome";export function TwoFactor() {    const [mode, setMode] = React.useState<"app" | "backup">("app");    const [code, setCode] = React.useState("");    const [backup, setBackup] = React.useState("");    const [trust, setTrust] = React.useState(true);    const [error, setError] = React.useState<string | null>(null);    const verify = () => {        if (mode === "app" && code.length < 6)            return setError("Enter all 6 digits of your code.");        if (mode === "backup" && !/^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(backup))            return setError("Enter a backup code in the format XXXX-XXXX.");        if (code === "000000")            return setError(                "That code didn't work. Check your authenticator app and try again.",            );        setError(null);        toast.add({            type: "success",            title: "Verified",            description: trust                ? "This device is trusted for 60 days."                : "Opening Cedarline.",        });    };    return (        <AuthCentered>            <AuthHeading                media={                    <AuthIconTile>                        <KeyRoundIcon />                    </AuthIconTile>                }                title={                    mode === "app"                        ? "Enter your verification code"                        : "Enter a backup code"                }                description={                    mode === "app"                        ? "Open your authenticator app and enter the 6-digit code for Oration."                        : "Use one of the backup codes you saved when you turned on two-factor."                }            />            <form                noValidate                onSubmit={(event) => {                    event.preventDefault();                    verify();                }}            >                <div className="mt-8">                    {mode === "app" ? (                        <Field data-invalid={error ? true : undefined}>                            <FieldLabel htmlFor="totp" className="sr-only">                                Verification code                            </FieldLabel>                            <InputOTP                                id="totp"                                maxLength={6}                                pattern="^\d+$"                                inputMode="numeric"                                autoComplete="one-time-code"                                value={code}                                onChange={(value) => {                                    setCode(value);                                    if (error) setError(null);                                }}                                containerClassName="w-full gap-2"                            >                                {[0, 3].map((start) => (                                    <React.Fragment key={start}>                                        {start > 0 ? (                                            <InputOTPSeparator className="text-subtle-foreground" />                                        ) : null}                                        <InputOTPGroup className="flex-1">                                            {[start, start + 1, start + 2].map(                                                (index) => (                                                    <InputOTPSlot                                                        key={index}                                                        index={index}                                                        aria-invalid={                                                            error                                                                ? true                                                                : undefined                                                        }                                                        className="h-12 flex-1 text-lg font-medium tabular-nums"                                                    />                                                ),                                            )}                                        </InputOTPGroup>                                    </React.Fragment>                                ))}                            </InputOTP>                            <FieldError>{error}</FieldError>                        </Field>                    ) : (                        <Field data-invalid={error ? true : undefined}>                            <FieldLabel htmlFor="backup-code">                                Backup code                            </FieldLabel>                            <Input                                id="backup-code"                                maxLength={9}                                placeholder="XXXX-XXXX"                                className="h-9 font-mono tracking-widest"                                value={backup}                                onChange={(event) => {                                    const raw = event.target.value                                        .toUpperCase()                                        .replace(/[^A-Z0-9]/g, "");                                    setBackup(                                        raw.length > 4                                            ? `${raw.slice(0, 4)}-${raw.slice(4, 8)}`                                            : raw,                                    );                                    if (error) setError(null);                                }}                            />                            <FieldDescription className="text-13">                                Each backup code works once.                            </FieldDescription>                            <FieldError>{error}</FieldError>                        </Field>                    )}                </div>                <FieldGroup className="mt-5 gap-5">                    <FieldLabel className="font-normal text-foreground">                        <Checkbox checked={trust} onCheckedChange={setTrust} />                        Trust this device for 60 days                    </FieldLabel>                    <Button type="submit" size="lg" className="w-full">                        Verify                    </Button>                </FieldGroup>            </form>            <div className="mt-4 flex flex-col items-center gap-2 text-sm">                <button                    type="button"                    className="text-muted-foreground transition-colors duration-150 hover:text-foreground"                    onClick={() => {                        setMode(mode === "app" ? "backup" : "app");                        setError(null);                    }}                >                    {mode === "app"                        ? "Use a backup code"                        : "Use your authenticator app"}                </button>                <button                    type="button"                    className="text-muted-foreground transition-colors duration-150 hover:text-foreground"                    onClick={() =>                        toast.add({                            title: "Code sent",                            description: "We texted the phone ending in 4417.",                        })                    }                >                    Text a code to the phone ending in 4417                </button>            </div>            <p className="mt-10 text-center text-sm text-pretty text-muted-foreground">                Can't use any of these? Ask a Cedarline admin to reset your                two-factor.            </p>        </AuthCentered>    );}

Account state: paused

The owner's view of a workspace paused for billing. Members see who to contact instead of the payment button.

Oration

Cedarline

Growth plan

Cedarline is paused

We couldn't charge the card on file for the Growth plan. Update your payment method to bring everyone back. Agents, sequences and workflows are paused, and nothing is deleted.

Paused workspaces keep their data for 60 days.

import { Button } from "@oration/canon/components/button";import { MonogramTile } from "@oration/canon/components/monogram-tile";import { toast } from "@oration/canon/components/toast";import { AccountHeader, AuthCentered, AuthHeading } from "./chrome";export function Paused() {    return (        <AuthCentered header={<AccountHeader email="maya@cedarline.com" />}>            <AuthHeading                media={                    <div className="flex items-center gap-3 rounded-xl bg-card p-4 shadow-border">                        <MonogramTile name="Cedarline" color="teal" size="lg" />                        <div className="min-w-0">                            <p className="text-sm font-medium">Cedarline</p>                            <p className="text-13 text-muted-foreground">                                Growth plan                            </p>                        </div>                    </div>                }                title="Cedarline is paused"                description="We couldn't charge the card on file for the Growth plan. Update your payment method to bring everyone back. Agents, sequences and workflows are paused, and nothing is deleted."            />            <div className="mt-8 flex flex-col gap-2">                <Button                    size="lg"                    className="w-full"                    onClick={() =>                        toast.add({                            title: "Billing",                            description: "Opening payment methods.",                        })                    }                >                    Update payment method                </Button>                <Button variant="outline" size="lg" className="w-full">                    Go to your workspaces                </Button>                <Button                    variant="ghost"                    size="lg"                    className="w-full text-muted-foreground"                >                    Log out                </Button>            </div>            <p className="mt-10 text-center text-sm text-muted-foreground">                Paused workspaces keep their data for 60 days.            </p>        </AuthCentered>    );}

Account state: suspended

A member whose access to one workspace was suspended. It names the date and the owner to contact.

Oration

Cedarline

48 members

Your access to Cedarline is suspended

An admin suspended your membership on September 28, 2026. Your account and your other workspaces aren't affected. If you think this is a mistake, contact Maya Okafor, Cedarline's owner.

import { Button } from "@oration/canon/components/button";import { MonogramTile } from "@oration/canon/components/monogram-tile";import { AccountHeader, AuthCentered, AuthHeading } from "./chrome";export function Suspended() {    return (        <AuthCentered header={<AccountHeader email="jordan@cedarline.com" />}>            <AuthHeading                media={                    <div className="flex items-center gap-3 rounded-xl bg-card p-4 shadow-border">                        <MonogramTile name="Cedarline" color="teal" size="lg" />                        <div className="min-w-0">                            <p className="text-sm font-medium">Cedarline</p>                            <p className="text-13 text-muted-foreground tabular-nums">                                48 members                            </p>                        </div>                    </div>                }                title="Your access to Cedarline is suspended"                description="An admin suspended your membership on September 28, 2026. Your account and your other workspaces aren't affected. If you think this is a mistake, contact Maya Okafor, Cedarline's owner."            />            <div className="mt-8 flex flex-col gap-2">                <Button size="lg" className="w-full">                    Go to your workspaces                </Button>                <Button                    variant="ghost"                    size="lg"                    className="w-full text-muted-foreground"                >                    Log out                </Button>            </div>        </AuthCentered>    );}

Measurements#

From the auth layouts. Auth pages use the large 36px controls throughout.

Auth measurements
PartValueClasses
Form column24remw-full max-w-sm
Header64px, 24px sidesh-16 px-6
Main padding24px sides, 32px top, 64px bottompx-6 pt-8 pb-16
Footer56pxh-14 px-6
SplitHalves from 1024px, 45 and 55 from 1280pxlg:grid-cols-2 xl:grid-cols-[45fr_55fr]
Product panel8px inset, 12px cornerssticky top-0 h-svh p-2, rounded-xl bg-sidebar
Controls36pxsize="lg", inputs h-9
Heading to providers32pxmt-8
Separator24px above and belowmy-6
Between fields16pxFieldGroup className="gap-4"
Code slots6 slots, 48px tall, 2 groups of 3h-12 flex-1 text-lg
Icon tile40px, 12px cornerssize-10 rounded-xl shadow-border

Input OTP's slots are 32px with 14px text and no size prop, so sign in overrides them to 44 to 48px at 18px, and paints its own error and success borders because aria-invalid on the input doesn't reach the slots. See the gaps on Input OTP.

Responsive behavior#

The form column is 24rem at every width. Only the split layout changes, at 1024px.

Oration

Log in to Oration

or

New to Oration?

  • SOC 2
  • ISO 27001
  • GDPR

Below 1024px log in is one column: the wordmark, the form in a 24rem column, the trust row under it, and the footer. The product panel doesn't mount at all.

Auth at each width
WidthWhat changes
390pxOne column; the form fills the width less 24px gutters. The trust row sits under the form on log in and sign up. Inputs use 16px text so iOS doesn't zoom.
768pxUnchanged; the column stays 24rem and centers.
1024pxLog in and sign up split in half; the trust row moves into the panel.
1440pxThe split becomes 45 and 55 from 1280px. Centered pages never change.

States#

Most of what auth pages do is change state, and every state has a sentence that says what to do next.

States by screen
ScreenStates
Log inField errors, a wrong password (That email and password don't match), a single sign-on domain that swaps the password field for Continue with your provider, a session-ended note, and lockout after 5 tries for 15 minutes.
Sign upField errors, the work email hint, password strength from Weak to Strong with three checks, and the terms error.
VerifyEntering, wrong (red slots and a shake), expired (a note and Send a new code), resend cooldown, locked after 5 tries, and verified (a green tile, then onward after 600ms).
Two-factorAuthenticator, text message with its own resend, backup code, a used backup code, and trust this device for 60 days.
AccountPaused (owner and member copy differ), suspended, deleted (Your account was deleted) and review sign-in (Was this you?).

Starter code#

The two layouts and the shape of a status page.

Auth layouts
// app/(auth)/(split)/layout.tsx: log in and sign upexport default function SplitLayout({ children }: { children: React.ReactNode }) {  return <AuthSplit panel={<ProductPanel />}>{children}</AuthSplit>;}// components/auth/auth-split.tsxexport function AuthSplit({ panel, children }: { panel: React.ReactNode; children: React.ReactNode }) {  return (    <AuthFrame className="lg:grid lg:grid-cols-2 xl:grid-cols-[45fr_55fr]">      <div className="flex min-h-svh flex-col">        <AuthHeader href="/login" />        <main id="main" className="flex flex-1 items-center justify-center px-6 pt-8 pb-16">          <div className="w-full max-w-sm">{children}</div>        </main>        {/* Below 1024px the trust row sits under the form */}        <TrustRow className="justify-center px-6 pb-2 lg:hidden" />        <AuthFooter />      </div>      <div className="sticky top-0 hidden h-svh p-2 lg:block">{panel}</div>    </AuthFrame>  );}// app/(auth)/(centered)/layout.tsx: verification, two-factor, resets and status pagesexport default function CenteredLayout({ children }: { children: React.ReactNode }) {  return (    <AuthFrame>      <AuthHeader href="/login" />      <AuthMain>{children}</AuthMain>      <AuthFooter />    </AuthFrame>  );}// A status page: heading with media, then stacked full-width actions<AuthHeading media={<AuthIconTile><KeyRoundIcon /></AuthIconTile>} title="Enter your verification code" description="…" /><div className="mt-8 flex flex-col gap-2">  <Button size="lg" className="w-full">Verify</Button>  <Button variant="ghost" size="lg" className="w-full text-muted-foreground">Log out</Button></div>
  • Put the trust row only in the split layout, so no other page can show it by accident.
  • Every auth page has exactly one filled button. Providers are outline, and secondary routes are ghost buttons or links.
  • Codes autofill with autoComplete="one-time-code" and submit on the sixth digit.

Rules that apply#

The One Filled Button Rule

Each view has at most one filled indigo button, the action the view exists for. Everything else is outline, secondary, ghost, link or a destructive tint. In a stack of proposals, only the expanded one shows its filled button.

The Sentence Case Rule

Everything is sentence case. No eyebrow or kicker label above a heading, no uppercase labels, no letter-spaced captions.

The Machine Mono Rule

Geist Mono is only for strings a machine produced or will parse: record and run IDs, API keys, tokens, codes, DNS records, template variables, E.164 phone numbers, timecodes. Figures, labels, keyboard keys and headings stay in Geist Sans.

The Label-Beside-Color Rule

Status is never color alone. A dot, tint or ring always travels with a text label (On track, At risk, Running, Passed), so the state reads in grayscale.

Do and don't#

  • SOC 2
  • ISO 27001
  • GDPR
Do. Name the compliance programs in plain text on log in and sign up.
SOC 2ISO 27001GDPR
Don't. Line up certification badges, or repeat the trust row on verification and status pages.
Do. Set the centered form straight on the plane.
Don't. Box it in a card. The page is already empty; a card adds an edge and nothing else.
Do. Say what happened and what to do: That code expired. Codes work for 10 minutes.
Don't. Show a generic error such as Something went wrong, or a red banner with an error code.