Files
pig/apps/web/src/pages/SignIn.tsx
T
claude 18d5f5bfc0
CI / verify (push) Successful in 7m10s
CI / publish (push) Has been skipped
Make Piggy part of the product rather than a guest in it
Piggy arrived as a chat panel bolted onto a CRM and then grew a workspace
around it. The layout was already right — the audit found the approval card
to be the best-designed object in the repo, and the account page's empty
panels less finished than anything in the workspace. What was wrong was
vocabulary: nobody had written the small things down, so both halves kept
inventing them.

Piggy was drawn with five different marks — a pig in the dock, a sparkle in
the sidebar and again on the model picker, a speech bubble on the Ask
buttons, and a stock robot glyph on every assistant message, which is the
one people look at most. There is now one mark. The composer, which is the
first control in the product since sign-in lands on /piggy, was the only
un-adapted shadcn field left: 6px radius against a 12px Send button it sat
8px from. A stat tile had been reinvented six times at three numeral scales,
and the same uppercase micro-label existed in five variants, two of them one
tab apart in the same rail. There were 63 hand-written font sizes: not a
scale, sixty-three opinions.

Underneath that, the focus ring was invisible. The global rule used
ring-accent, which Tailwind deliberately aliases onto the hover tint, so the
ring measured 1.01:1 against the light canvas — no visible focus indicator
anywhere in the product, for any accent, in either theme. It is ring-brand
now and measures 17:1. The warning, positive and info tones were darkened
until each clears 4.5:1 on a card, on inset and on its own chip, and the
light canvas moved to 98% so a card lifts without leaning on its shadow.

The mobile work is the part worth reading. A landscape phone gave the
transcript 28% of the viewport and a keyboard-up phone 16%, against a 45%
floor — and the fixed tab bar painted over the composer, covering the safety
sentence and half the Send button, because two source comments asserted the
bar stood down on short viewports and it never had. Both fixed and measured
by hit-testing rather than by screenshot. The composer itself was 64px tall
for a blank second line nobody typed, because the auto-resize effect sizes
to scrollHeight and scrollHeight counts rows — a CSS height could not win
against an inline style, so the attribute was the honest lever.

Verified across both themes driven through the app's own control: no
horizontal overflow on 15 routes at four viewports, 672 stat values that fit,
297 labels at exactly 11px/500, Escape returning focus to its opener rather
than the body on every overlay, and a rejected write no longer reporting
"Succeeded" with a green check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 18:22:15 -07:00

226 lines
8.2 KiB
TypeScript

/**
* Sign-in.
*
* Two methods, because they suit different situations. A password is faster
* for someone who uses PIG daily and has it in a manager; a magic link needs
* no credential at all and is the better answer for someone signing in once
* from a phone. Neither is stored by PIG — both are handled entirely by the
* identity provider, and PIG only ever sees the resulting token.
*
* Password is the default tab because the alternative — defaulting to a link
* and making daily users switch every time — is the more annoying of the two
* mistakes.
*/
import { useState } from 'react';
import { Mail } from 'lucide-react';
import { getSupabase, type PublicConfig } from '@/lib/api';
import { Button, Input, Label } from '@/components/ui';
import { FormField } from '@/components/ui/form-field';
import { AuthShell } from '@/components/AuthShell';
import { usePageTitle } from '@/lib/title';
type Method = 'password' | 'link';
export function SignIn({
config,
onCreateAccount,
}: {
config: PublicConfig;
onCreateAccount: () => void;
}) {
usePageTitle('Sign in');
const [method, setMethod] = useState<Method>('password');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [status, setStatus] = useState<'idle' | 'busy' | 'sent' | 'error'>('idle');
const [message, setMessage] = useState('');
async function submit(event: React.FormEvent) {
event.preventDefault();
const supabase = getSupabase();
if (!supabase) {
setStatus('error');
setMessage('Authentication is not configured on this deployment.');
return;
}
setStatus('busy');
setMessage('');
if (method === 'password') {
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) {
setStatus('error');
// Supabase deliberately returns the same message for a wrong password
// and an unknown address, which is correct — distinguishing them tells
// an attacker which addresses are registered.
setMessage(error.message);
return;
}
// The auth state listener in App.tsx picks this up and re-fetches; no
// navigation is needed here.
setStatus('idle');
return;
}
const { error } = await supabase.auth.signInWithOtp({
email,
options: { emailRedirectTo: window.location.origin },
});
if (error) {
setStatus('error');
setMessage(error.message);
return;
}
setStatus('sent');
}
return (
<AuthShell>
<div className="auth-panel rounded-2xl border border-border/80 bg-surface/90 p-5 shadow-2xl backdrop-blur-xl sm:p-7 lg:rounded-none lg:border-0 lg:bg-transparent lg:p-0 lg:shadow-none lg:backdrop-blur-none">
{status === 'sent' ? (
<div className="flex flex-col items-center gap-4 py-4 text-center sm:py-8">
<span className="flex size-12 items-center justify-center rounded-full border border-border bg-surface-2">
<Mail className="size-5 text-accent-fg" aria-hidden />
</span>
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">Check your email</h1>
<p className="max-w-sm text-sm leading-6 text-muted">
A sign-in link is on its way to {email}. It expires shortly, so use it soon.
</p>
</div>
<button
type="button"
onClick={() => setStatus('idle')}
className="tap text-sm font-medium text-accent-fg"
>
Use a different address
</button>
</div>
) : (
<>
<div className="flex flex-col gap-2">
<Label>Private workspace</Label>
<h1 className="text-3xl font-medium tracking-[-0.035em] sm:text-4xl">
Sign in to PIG
</h1>
<p className="max-w-md text-sm leading-6 text-muted">
Authentication stays with the deployment's identity provider. PIG never stores your
password.
</p>
</div>
<div
role="tablist"
aria-label="Sign-in method"
className="mt-6 flex w-full border-b border-border"
>
{(
[
{ key: 'password', label: 'Password' },
{ key: 'link', label: 'Email link' },
] as const
).map((option) => (
<button
key={option.key}
type="button"
role="tab"
aria-selected={method === option.key}
onClick={() => {
setMethod(option.key);
setStatus('idle');
setMessage('');
}}
className={[
'tap relative flex flex-1 items-center justify-center px-3 text-sm font-medium transition-colors duration-1 ease-enter',
method === option.key
? 'text-fg after:absolute after:inset-x-3 after:-bottom-px after:h-px after:bg-primary'
: 'text-muted hover:text-fg',
].join(' ')}
>
{option.label}
</button>
))}
</div>
<form onSubmit={submit} className="mt-5 flex flex-col gap-4">
<FormField label="Email">
<Input
id="sign-in-email"
name="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@company.com"
autoComplete="username"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
/>
</FormField>
{method === 'password' ? (
<FormField label="Password">
<Input
id="sign-in-password"
name="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
// `current-password` is what lets a password manager
// offer to fill, and iOS to offer a saved credential.
autoComplete="current-password"
/>
</FormField>
) : null}
<Button
type="submit"
variant="primary"
className="mt-1 w-full"
disabled={status === 'busy'}
>
{status === 'busy'
? method === 'password'
? 'Signing in'
: 'Sending'
: method === 'password'
? 'Sign in'
: 'Email me a sign-in link'}
</Button>
{status === 'error' ? (
<p className="text-sm text-danger" role="alert">
{message}
</p>
) : null}
{config.canSelfRegister ? (
<div className="mt-1 border-t border-border pt-4">
<p className="text-sm leading-6 text-muted">
Have a PIG invite code but no account? Registration remains closed on the shared
identity provider.
</p>
<button
type="button"
onClick={onCreateAccount}
className="tap -ml-2 inline-flex items-center px-2 text-sm font-medium text-accent-fg"
>
Create an account
</button>
</div>
) : config.inviteRequired ? (
<p className="pt-1 text-center text-xs text-muted">
This workspace is invite-only. Ask an administrator to provision access; this
screen never opens self-registration.
</p>
) : null}
</form>
</>
)}
</div>
</AuthShell>
);
}