Polish the public auth experience
CI / verify (push) Successful in 3m26s
CI / publish (push) Has been skipped

Replace the stacked sign-in card with a responsive public shell, a local monochrome compute field, restrained grain and reduced-motion-safe drift. Share the anonymous header with Learn so public navigation stays consistent, and carry the corrected spacing through registration and profile setup.
This commit is contained in:
2026-08-13 20:51:34 -07:00
parent b63ed0181f
commit 9db53cb36f
9 changed files with 574 additions and 430 deletions
+4 -16
View File
@@ -11,7 +11,6 @@ import { IdentityProvider, useIdentityQuery } from '@/lib/identity';
import { LayoutProvider } from '@/lib/layout';
import { PiggyContextProvider } from '@/lib/piggy-context';
import { PlatformAudioProvider } from '@/lib/audio';
import { AudioControl } from '@/components/AudioControl';
import { Shell } from '@/components/Shell';
import { SignIn } from '@/pages/SignIn';
import { CreateProfile } from '@/pages/CreateProfile';
@@ -108,25 +107,14 @@ export function App() {
}
/**
* The signed-out screens render outside Shell, so they get no app header — and
* therefore no way to stop the music. Audio a visitor cannot switch off is the
* worst version of this feature, so every unauthenticated screen gets the
* control pinned in a corner.
* The signed-out screens render outside Shell. Their shared AuthShell supplies
* the public header and its music control; keeping this boundary component
* means the auth gate does not need to know anything about that presentation.
*
* Learn is not wrapped: it brings its own chrome and already hosts one.
*/
function SignedOut({ children }: { children: React.ReactNode }) {
return (
<>
{children}
<div
className="fixed right-3 z-50 rounded-full border border-border bg-surface/85 shadow-sm backdrop-blur"
style={{ bottom: 'calc(0.75rem + var(--safe-bottom))' }}
>
<AudioControl />
</div>
</>
);
return children;
}
/**
+37
View File
@@ -0,0 +1,37 @@
/**
* Public auth composition: generated compute field, grain and real form UI.
*
* The artwork is decoration only. Auth copy and controls stay in the normal
* document flow, so a missing image changes the mood rather than the task.
*/
import type { ReactNode } from 'react';
import { PublicHeader } from './PublicHeader';
export function AuthShell({ children, onSignIn }: { children: ReactNode; onSignIn?: () => void }) {
return (
<div className="auth-shell relative isolate flex min-h-dvh min-w-0 flex-col overflow-hidden bg-bg text-fg">
<PublicHeader current="sign-in" onSignIn={onSignIn} />
<main className="relative grid min-w-0 flex-1 lg:grid-cols-[minmax(0,1.08fr)_minmax(30rem,0.92fr)]">
<div
className="auth-visual pointer-events-none absolute inset-0 lg:relative lg:inset-auto"
aria-hidden
>
<div className="auth-field-motion absolute -inset-[4%]">
<img
src="/images/pig-compute-field.webp"
alt=""
className="size-full object-cover object-[46%_50%]"
decoding="async"
fetchPriority="high"
/>
</div>
<div className="auth-visual-vignette absolute inset-0" />
</div>
<section className="relative z-10 flex min-w-0 items-center justify-center px-4 py-6 sm:px-8 sm:py-10 lg:border-l lg:border-border/70 lg:bg-bg/90 lg:px-12 xl:px-16">
<div className="w-full min-w-0 max-w-lg">{children}</div>
</section>
</main>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
/**
* The small amount of chrome shared by every public PIG screen.
*
* These routes deliberately live outside the authenticated Shell, but they
* should still feel like the same product. Keeping the wordmark, Learn link,
* sign-in affordance and music control here prevents the auth and shared
* Learn pages from drifting into separate mini-sites.
*/
import { AudioControl } from './AudioControl';
import { PiggyLogo } from './PiggyMark';
import { cn } from './ui';
type PublicDestination = 'learn' | 'sign-in';
export function PublicHeader({
current,
onSignIn,
}: {
current?: PublicDestination;
onSignIn?: () => void;
}) {
const itemClass = (destination: PublicDestination) =>
cn(
'tap relative inline-flex items-center px-2 text-sm font-medium transition-colors',
current === destination ? 'text-fg' : 'text-muted hover:text-fg',
current === destination &&
'after:absolute after:inset-x-2 after:bottom-1 after:h-px after:bg-current',
);
return (
<header className="public-header relative z-30 border-b border-border/70 bg-bg/80 backdrop-blur-xl">
<div className="mx-auto flex h-16 w-full min-w-0 max-w-7xl items-center gap-2 px-4 sm:px-6 lg:px-8">
<a href="/" className="tap flex shrink-0 items-center rounded-lg" aria-label="PIG home">
<PiggyLogo />
</a>
<nav aria-label="Public navigation" className="ml-auto flex shrink-0 items-center gap-1">
<a
href="/learn"
aria-current={current === 'learn' ? 'page' : undefined}
className={itemClass('learn')}
>
Learn
</a>
<AudioControl className="public-header-audio" />
{onSignIn ? (
<button type="button" onClick={onSignIn} className={itemClass('sign-in')}>
Sign in
</button>
) : current === 'sign-in' ? (
<span aria-current="page" className={itemClass('sign-in')}>
Sign in
</span>
) : (
<a href="/" className={itemClass('sign-in')}>
Sign in
</a>
)}
</nav>
</div>
</header>
);
}
+76
View File
@@ -177,6 +177,82 @@
background-attachment: fixed;
}
/*
* The auth surface is intentionally monochrome even if a signed-in user has
* selected another accent. It is public brand chrome, and forcing the local
* dark token set keeps controls legible without changing the stored theme.
*/
.auth-shell {
--bg: 240 10% 3%;
--surface: 240 7% 6%;
--surface-2: 240 5% 11%;
--border: 240 4% 22%;
--fg: 0 0% 98%;
--muted: 240 5% 67%;
--accent: 0 0% 98%;
--accent-fg: 0 0% 98%;
--accent-on: 240 6% 10%;
--accent-subtle: 240 4% 16%;
color-scheme: dark;
}
.auth-field-motion {
animation: auth-field-drift 26s ease-in-out infinite alternate;
will-change: transform;
}
.auth-visual {
opacity: 0.52;
}
.auth-visual::after {
position: absolute;
inset: 0;
content: '';
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.92' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.72'/%3E%3C/svg%3E");
background-size: 180px 180px;
mix-blend-mode: soft-light;
opacity: 0.14;
}
.auth-visual-vignette {
background:
linear-gradient(to bottom, hsl(var(--bg) / 0.18), hsl(var(--bg) / 0.72) 34%, hsl(var(--bg)) 100%),
linear-gradient(to right, hsl(var(--bg) / 0.18), hsl(var(--bg) / 0.42));
}
@media (min-width: 1024px) {
.auth-visual {
opacity: 1;
}
.auth-visual-vignette {
background:
linear-gradient(to right, transparent 48%, hsl(var(--bg) / 0.28) 78%, hsl(var(--bg) / 0.72)),
linear-gradient(to bottom, hsl(var(--bg) / 0.06), transparent 18%, hsl(var(--bg) / 0.28));
}
}
@keyframes auth-field-drift {
0% {
transform: translate3d(-1.4%, -0.8%, 0) scale(1.045);
}
50% {
transform: translate3d(0.8%, 0.6%, 0) scale(1.075);
}
100% {
transform: translate3d(-0.3%, 1.1%, 0) scale(1.055);
}
}
@media (prefers-reduced-motion: reduce) {
.auth-field-motion {
animation: none;
transform: scale(1.045);
will-change: auto;
}
}
/* Tabular figures keep money and percentages from jittering as they
update, which matters on a dashboard that refreshes. */
.nums {
+96 -97
View File
@@ -9,8 +9,8 @@
import { useState } from 'react';
import { TEAMS, TEAM_DESCRIPTIONS, TEAM_LABELS, type Team } from '@pig/core';
import { ApiError, post, type PublicConfig } from '@/lib/api';
import { Button, Card, CardContent, Input } from '@/components/ui';
import { PiggyMark } from '@/components/PiggyMark';
import { Button, Input } from '@/components/ui';
import { AuthShell } from '@/components/AuthShell';
import { usePageTitle } from '@/lib/title';
export function CreateProfile({
@@ -51,110 +51,109 @@ export function CreateProfile({
}
return (
<div className="flex min-h-dvh items-center justify-center bg-bg px-6 py-12">
<div className="w-full max-w-md">
<div className="mb-6 flex flex-col items-center gap-3 text-center">
<PiggyMark className="h-12 w-12 text-fg" title="pig" />
<div>
<h1 className="text-xl font-semibold tracking-tight">Set up your profile</h1>
<p className="mt-1 text-sm text-muted">
You're already authenticated. This creates a PIG profile only after workspace access is verified.
</p>
</div>
<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">
<div className="flex flex-col gap-2">
<p className="text-xs font-medium uppercase tracking-[0.14em] text-muted">
Private workspace
</p>
<h1 className="text-3xl font-medium tracking-[-0.035em] sm:text-4xl">
Set up your profile
</h1>
<p className="text-sm leading-6 text-muted">
You're already authenticated. This creates a PIG profile only after workspace access is
verified.
</p>
</div>
<Card>
<CardContent className="pt-5">
<form onSubmit={submit} className="space-y-4">
<label className="block" htmlFor="profile-name">
<span className="mb-1 block text-sm font-medium">Your name</span>
<Input
id="profile-name"
name="name"
required
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="name"
placeholder="Alex Ferguson"
/>
</label>
<form onSubmit={submit} className="mt-6 flex flex-col gap-4">
<label className="block" htmlFor="profile-name">
<span className="mb-1 block text-sm font-medium">Your name</span>
<Input
id="profile-name"
name="name"
required
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="name"
placeholder="Alex Ferguson"
/>
</label>
<label className="block" htmlFor="profile-job-title">
<span className="mb-1 block text-sm font-medium">
Title <span className="font-normal text-muted">(optional)</span>
</span>
<Input
id="profile-job-title"
name="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Head of Growth"
/>
</label>
<label className="block" htmlFor="profile-job-title">
<span className="mb-1 block text-sm font-medium">
Title <span className="font-normal text-muted">(optional)</span>
</span>
<Input
id="profile-job-title"
name="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Head of Growth"
/>
</label>
<fieldset>
<legend className="mb-2 text-sm font-medium">Which team are you on?</legend>
<div className="space-y-2">
{TEAMS.map((value) => (
<label
key={value}
className={[
'flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors',
team === value
? 'border-primary bg-accent-subtle'
: 'border-border hover:bg-surface-2',
].join(' ')}
>
<input
type="radio"
name="team"
value={value}
checked={team === value}
onChange={() => setTeam(value)}
className="mt-0.5 h-4 w-4 accent-[hsl(var(--accent))]"
/>
<span className="min-w-0">
<span className="block text-sm font-medium">{TEAM_LABELS[value]}</span>
<span className="block text-xs text-muted">
{TEAM_DESCRIPTIONS[value]}
</span>
</span>
</label>
))}
</div>
<p className="mt-2 text-xs text-muted">
You can be added to more teams later.
</p>
</fieldset>
{config.inviteRequired ? (
<label className="block" htmlFor="profile-invite-code">
<span className="mb-1 block text-sm font-medium">Invite code</span>
<Input
id="profile-invite-code"
name="inviteCode"
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)}
placeholder="Ask an administrator"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
<fieldset>
<legend className="mb-2 text-sm font-medium">Which team are you on?</legend>
<div className="flex flex-col gap-2">
{TEAMS.map((value) => (
<label
key={value}
className={[
'flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors',
team === value
? 'border-primary bg-accent-subtle'
: 'border-border hover:bg-surface-2',
].join(' ')}
>
<input
type="radio"
name="team"
value={value}
checked={team === value}
onChange={() => setTeam(value)}
className="mt-0.5 h-4 w-4 accent-[hsl(var(--accent))]"
/>
<span className="mt-1 block text-xs text-muted">
Not needed if your address is configured as an administrator.
<span className="min-w-0">
<span className="block text-sm font-medium">{TEAM_LABELS[value]}</span>
<span className="block text-xs text-muted">{TEAM_DESCRIPTIONS[value]}</span>
</span>
</label>
) : null}
))}
</div>
<p className="mt-2 text-xs text-muted">You can be added to more teams later.</p>
</fieldset>
{error ? <p className="text-sm text-danger" role="alert">{error}</p> : null}
{config.inviteRequired ? (
<label className="block" htmlFor="profile-invite-code">
<span className="mb-1 block text-sm font-medium">Invite code</span>
<Input
id="profile-invite-code"
name="inviteCode"
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)}
placeholder="Ask an administrator"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
/>
<span className="mt-1 block text-xs text-muted">
Not needed if your address is configured as an administrator.
</span>
</label>
) : null}
<Button type="submit" variant="primary" className="w-full" disabled={busy}>
{busy ? 'Creating' : 'Join the workspace'}
</Button>
</form>
</CardContent>
</Card>
{error ? (
<p className="text-sm text-danger" role="alert">
{error}
</p>
) : null}
<Button type="submit" variant="primary" className="w-full" disabled={busy}>
{busy ? 'Creating' : 'Join the workspace'}
</Button>
</form>
</div>
</div>
</AuthShell>
);
}
+2 -17
View File
@@ -55,8 +55,7 @@ import { usePageTitle } from '@/lib/title';
import { usePiggyContext } from '@/lib/piggy-context';
import { toPiggyPageRoute } from '@pig/core';
import { Badge, Button, Card, EmptyState, Skeleton } from '@/components/ui';
import { AudioControl } from '@/components/AudioControl';
import { PiggyMark } from '@/components/PiggyMark';
import { PublicHeader } from '@/components/PublicHeader';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AddResourceDialog } from '@/components/learn/AddResourceDialog';
import { LearnAccessHero } from '@/components/learn/LearnAccessHero';
@@ -414,21 +413,7 @@ function CodeHolderView({
function AnonFrame({ children }: { children: ReactNode }) {
return (
<div className="app-canvas flex min-h-dvh min-w-0 flex-col">
<header className="mx-auto flex w-full min-w-0 max-w-5xl items-center justify-between gap-3 px-4 py-4 sm:px-6">
<span className="flex min-w-0 items-center gap-2">
<PiggyMark className="size-7 shrink-0 text-fg" aria-hidden />
<span className="min-w-0 truncate font-semibold tracking-tight">pig</span>
</span>
<span className="flex shrink-0 items-center gap-1">
{/* The anonymous page renders outside Shell, so it does not get the
app header — but it DOES get the music, and music with no way to
stop it is the worst version of this feature. */}
<AudioControl />
<Button variant="ghost" size="sm" className="shrink-0" asChild>
<a href="/">Sign in</a>
</Button>
</span>
</header>
<PublicHeader current="learn" />
<main className="mx-auto flex w-full min-w-0 max-w-5xl flex-1 flex-col gap-10 px-4 pb-14 pt-2 sm:px-6 sm:pb-20">
{children}
</main>
+152 -151
View File
@@ -11,8 +11,8 @@ import { useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { TEAMS, TEAM_DESCRIPTIONS, TEAM_LABELS, type Team } from '@pig/core';
import { ApiError, getSupabase, post, type PublicConfig } from '@/lib/api';
import { Button, Card, CardContent, Input } from '@/components/ui';
import { PiggyMark } from '@/components/PiggyMark';
import { Button, Input } from '@/components/ui';
import { AuthShell } from '@/components/AuthShell';
import { usePageTitle } from '@/lib/title';
export function Register({
@@ -66,9 +66,7 @@ export function Register({
}
onRegistered();
} catch (err) {
setError(
err instanceof ApiError ? err.message : 'Could not create your account. Try again.',
);
setError(err instanceof ApiError ? err.message : 'Could not create your account. Try again.');
} finally {
setBusy(false);
}
@@ -76,160 +74,163 @@ export function Register({
if (!config.canSelfRegister) {
return (
<Centered>
<Card>
<CardContent className="space-y-3 pt-5 text-center">
<p className="font-medium">Registration is not open here</p>
<p className="text-sm text-muted">
This deployment cannot create accounts on its own. Ask an administrator to
provision one for you, then sign in.
</p>
<Button variant="outline" onClick={onBack} className="w-full">
Back to sign in
</Button>
</CardContent>
</Card>
</Centered>
<AuthShell onSignIn={onBack}>
<div className="auth-panel flex flex-col gap-4 rounded-2xl border border-border/80 bg-surface/90 p-6 text-center shadow-2xl backdrop-blur-xl sm:p-8 lg:rounded-none lg:border-0 lg:bg-transparent lg:shadow-none lg:backdrop-blur-none">
<p className="font-medium">Registration is not open here</p>
<p className="text-sm text-muted">
This deployment cannot create accounts on its own. Ask an administrator to provision one
for you, then sign in.
</p>
<Button variant="outline" onClick={onBack} className="w-full">
Back to sign in
</Button>
</div>
</AuthShell>
);
}
return (
<Centered>
<div className="mb-6 flex flex-col items-center gap-3 text-center">
<PiggyMark className="h-12 w-12 text-fg" title="pig" />
<div>
<h1 className="text-xl font-semibold tracking-tight">Create your account</h1>
<p className="mt-1 text-sm text-muted">
Use an email you control. A valid PIG invite is required before the server creates workspace access.
<AuthShell onSignIn={onBack}>
<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">
<div className="flex flex-col gap-2">
<p className="text-xs font-medium uppercase tracking-[0.14em] text-muted">
Private workspace
</p>
<p className="mt-2 text-xs text-muted">
<h1 className="text-3xl font-medium tracking-[-0.035em] sm:text-4xl">
Create your account
</h1>
<p className="text-sm leading-6 text-muted">
Use an email you control. A valid PIG invite is required before the server creates
workspace access.
</p>
<p className="text-xs leading-5 text-muted">
This flow does not enable open registration on the shared identity provider.
</p>
</div>
<form onSubmit={submit} className="mt-6 flex flex-col gap-4">
<label className="block" htmlFor="register-invite-code">
<span className="mb-1 block text-sm font-medium">Invite code</span>
<Input
id="register-invite-code"
name="inviteCode"
required
value={form.inviteCode}
onChange={set('inviteCode')}
placeholder="Ask whoever invited you"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
/>
</label>
<label className="block" htmlFor="register-name">
<span className="mb-1 block text-sm font-medium">Your name</span>
<Input
id="register-name"
name="name"
required
value={form.name}
onChange={set('name')}
autoComplete="name"
/>
</label>
<label className="block" htmlFor="register-email">
<span className="mb-1 block text-sm font-medium">Email</span>
<Input
id="register-email"
name="email"
type="email"
required
value={form.email}
onChange={set('email')}
placeholder="you@anywhere.com"
autoComplete="email"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
/>
</label>
<label className="block" htmlFor="register-password">
<span className="mb-1 block text-sm font-medium">Password</span>
<Input
id="register-password"
name="password"
type="password"
required
minLength={8}
value={form.password}
onChange={set('password')}
// `new-password` is what prompts a password manager to offer a
// generated one rather than autofilling an existing login.
autoComplete="new-password"
/>
<span className="mt-1 block text-xs text-muted">At least 8 characters.</span>
</label>
<label className="block" htmlFor="register-title">
<span className="mb-1 block text-sm font-medium">
Title <span className="font-normal text-muted">(optional)</span>
</span>
<Input
id="register-title"
name="title"
value={form.title}
onChange={set('title')}
placeholder="Head of Growth"
/>
</label>
<fieldset>
<legend className="mb-2 text-sm font-medium">Which team are you on?</legend>
<div className="flex flex-col gap-2">
{TEAMS.map((value) => (
<label
key={value}
className={[
'flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors',
team === value
? 'border-primary bg-accent-subtle'
: 'border-border hover:bg-surface-2',
].join(' ')}
>
<input
type="radio"
name="team"
checked={team === value}
onChange={() => setTeam(value)}
className="mt-0.5 h-4 w-4 accent-[hsl(var(--accent))]"
/>
<span className="min-w-0">
<span className="block text-sm font-medium">{TEAM_LABELS[value]}</span>
<span className="block text-xs text-muted">{TEAM_DESCRIPTIONS[value]}</span>
</span>
</label>
))}
</div>
</fieldset>
{error ? (
<p className="text-sm text-danger" role="alert">
{error}
</p>
) : null}
<Button type="submit" variant="primary" className="w-full" disabled={busy}>
{busy ? 'Creating your account…' : 'Create account'}
</Button>
<button
type="button"
onClick={onBack}
className="tap flex w-full items-center justify-center gap-1.5 text-sm font-medium text-muted"
>
<ArrowLeft className="h-3.5 w-3.5" aria-hidden />I already have an account
</button>
</form>
</div>
<Card>
<CardContent className="pt-5">
<form onSubmit={submit} className="space-y-4">
<label className="block" htmlFor="register-invite-code">
<span className="mb-1 block text-sm font-medium">Invite code</span>
<Input
id="register-invite-code"
name="inviteCode"
required
value={form.inviteCode}
onChange={set('inviteCode')}
placeholder="Ask whoever invited you"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
/>
</label>
<label className="block" htmlFor="register-name">
<span className="mb-1 block text-sm font-medium">Your name</span>
<Input id="register-name" name="name" required value={form.name} onChange={set('name')} autoComplete="name" />
</label>
<label className="block" htmlFor="register-email">
<span className="mb-1 block text-sm font-medium">Email</span>
<Input
id="register-email"
name="email"
type="email"
required
value={form.email}
onChange={set('email')}
placeholder="you@anywhere.com"
autoComplete="email"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
/>
</label>
<label className="block" htmlFor="register-password">
<span className="mb-1 block text-sm font-medium">Password</span>
<Input
id="register-password"
name="password"
type="password"
required
minLength={8}
value={form.password}
onChange={set('password')}
// `new-password` is what prompts a password manager to offer a
// generated one rather than autofilling an existing login.
autoComplete="new-password"
/>
<span className="mt-1 block text-xs text-muted">At least 8 characters.</span>
</label>
<label className="block" htmlFor="register-title">
<span className="mb-1 block text-sm font-medium">
Title <span className="font-normal text-muted">(optional)</span>
</span>
<Input id="register-title" name="title" value={form.title} onChange={set('title')} placeholder="Head of Growth" />
</label>
<fieldset>
<legend className="mb-2 text-sm font-medium">Which team are you on?</legend>
<div className="space-y-2">
{TEAMS.map((value) => (
<label
key={value}
className={[
'flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors',
team === value
? 'border-primary bg-accent-subtle'
: 'border-border hover:bg-surface-2',
].join(' ')}
>
<input
type="radio"
name="team"
checked={team === value}
onChange={() => setTeam(value)}
className="mt-0.5 h-4 w-4 accent-[hsl(var(--accent))]"
/>
<span className="min-w-0">
<span className="block text-sm font-medium">{TEAM_LABELS[value]}</span>
<span className="block text-xs text-muted">{TEAM_DESCRIPTIONS[value]}</span>
</span>
</label>
))}
</div>
</fieldset>
{error ? (
<p className="text-sm text-danger" role="alert">
{error}
</p>
) : null}
<Button type="submit" variant="primary" className="w-full" disabled={busy}>
{busy ? 'Creating your account…' : 'Create account'}
</Button>
<button
type="button"
onClick={onBack}
className="tap flex w-full items-center justify-center gap-1.5 text-sm font-medium text-muted"
>
<ArrowLeft className="h-3.5 w-3.5" aria-hidden />
I already have an account
</button>
</form>
</CardContent>
</Card>
</Centered>
);
}
function Centered({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-dvh items-center justify-center bg-bg px-6 py-12">
<div className="w-full max-w-sm">{children}</div>
</div>
</AuthShell>
);
}
+144 -149
View File
@@ -12,10 +12,10 @@
* mistakes.
*/
import { useState } from 'react';
import { KeyRound, Mail } from 'lucide-react';
import { Mail } from 'lucide-react';
import { getSupabase, type PublicConfig } from '@/lib/api';
import { Button, Card, CardContent, Input } from '@/components/ui';
import { PiggyMark } from '@/components/PiggyMark';
import { Button, Input } from '@/components/ui';
import { AuthShell } from '@/components/AuthShell';
import { usePageTitle } from '@/lib/title';
type Method = 'password' | 'link';
@@ -75,159 +75,154 @@ export function SignIn({
}
return (
<div className="flex min-h-dvh items-center justify-center bg-bg px-6 py-12">
<div className="w-full max-w-sm">
<div className="mb-8 flex flex-col items-center gap-3 text-center">
<PiggyMark className="h-14 w-14 text-fg" title="pig" />
<div>
<h1 className="text-2xl font-semibold lowercase tracking-tight">pig</h1>
<p className="text-sm text-muted">Prime Intellect Growth</p>
<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>
<Card>
<CardContent className="pt-5">
{status === 'sent' ? (
<div className="space-y-3 text-center">
<Mail className="mx-auto h-8 w-8 text-accent-fg" aria-hidden />
<div>
<p className="font-medium">Check your email</p>
<p className="mt-1 text-sm text-muted">
A sign-in link is on its way to {email}. It expires shortly, so use it
soon.
</p>
</div>
) : (
<>
<div className="flex flex-col gap-2">
<p className="text-xs font-medium uppercase tracking-[0.14em] text-muted">
Private workspace
</p>
<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 sm:text-[0.9375rem]">
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"
onClick={() => setStatus('idle')}
className="tap text-sm font-medium text-accent-fg"
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',
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(' ')}
>
Use a different address
{option.label}
</button>
</div>
) : (
<>
<div className="mb-5">
<p className="text-xs font-medium uppercase tracking-[0.14em] text-muted">
Private workspace
))}
</div>
<form onSubmit={submit} className="mt-5 flex flex-col gap-4">
<label className="block" htmlFor="sign-in-email">
<span className="mb-1 block text-sm font-medium">Email</span>
<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}
/>
</label>
{method === 'password' ? (
<label className="block" htmlFor="sign-in-password">
<span className="mb-1 block text-sm font-medium">Password</span>
<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"
/>
</label>
) : 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>
<h2 className="mt-1 text-lg font-semibold">Sign in to PIG</h2>
<p className="mt-1 text-sm 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="mb-4 inline-flex w-full rounded-lg bg-surface-2 p-1"
>
{(
[
{ key: 'password', label: 'Password', icon: KeyRound },
{ key: 'link', label: 'Email link', icon: Mail },
] 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 flex flex-1 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors',
method === option.key ? 'bg-surface text-fg shadow-sm' : 'text-muted',
].join(' ')}
>
<option.icon className="h-4 w-4" aria-hidden />
{option.label}
</button>
))}
</div>
<form onSubmit={submit} className="space-y-3">
<label className="block" htmlFor="sign-in-email">
<span className="mb-1 block text-sm font-medium">Email</span>
<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}
/>
</label>
{method === 'password' ? (
<label className="block" htmlFor="sign-in-password">
<span className="mb-1 block text-sm font-medium">Password</span>
<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"
/>
</label>
) : null}
<Button
type="submit"
variant="primary"
className="w-full"
disabled={status === 'busy'}
<button
type="button"
onClick={onCreateAccount}
className="tap -ml-2 inline-flex items-center px-2 text-sm font-medium text-accent-fg"
>
{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="border-t border-border pt-3 text-center">
<p className="text-sm 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 mt-1 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>
</>
)}
</CardContent>
</Card>
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>
</div>
</AuthShell>
);
}