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
Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

+4 -16
View File
@@ -11,7 +11,6 @@ import { IdentityProvider, useIdentityQuery } from '@/lib/identity';
import { LayoutProvider } from '@/lib/layout'; import { LayoutProvider } from '@/lib/layout';
import { PiggyContextProvider } from '@/lib/piggy-context'; import { PiggyContextProvider } from '@/lib/piggy-context';
import { PlatformAudioProvider } from '@/lib/audio'; import { PlatformAudioProvider } from '@/lib/audio';
import { AudioControl } from '@/components/AudioControl';
import { Shell } from '@/components/Shell'; import { Shell } from '@/components/Shell';
import { SignIn } from '@/pages/SignIn'; import { SignIn } from '@/pages/SignIn';
import { CreateProfile } from '@/pages/CreateProfile'; 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 * The signed-out screens render outside Shell. Their shared AuthShell supplies
* therefore no way to stop the music. Audio a visitor cannot switch off is the * the public header and its music control; keeping this boundary component
* worst version of this feature, so every unauthenticated screen gets the * means the auth gate does not need to know anything about that presentation.
* control pinned in a corner.
* *
* Learn is not wrapped: it brings its own chrome and already hosts one. * Learn is not wrapped: it brings its own chrome and already hosts one.
*/ */
function SignedOut({ children }: { children: React.ReactNode }) { function SignedOut({ children }: { children: React.ReactNode }) {
return ( return children;
<>
{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>
</>
);
} }
/** /**
+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; 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 /* Tabular figures keep money and percentages from jittering as they
update, which matters on a dashboard that refreshes. */ update, which matters on a dashboard that refreshes. */
.nums { .nums {
+96 -97
View File
@@ -9,8 +9,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { TEAMS, TEAM_DESCRIPTIONS, TEAM_LABELS, type Team } from '@pig/core'; import { TEAMS, TEAM_DESCRIPTIONS, TEAM_LABELS, type Team } from '@pig/core';
import { ApiError, post, type PublicConfig } from '@/lib/api'; import { ApiError, post, type PublicConfig } from '@/lib/api';
import { Button, Card, CardContent, Input } from '@/components/ui'; import { Button, Input } from '@/components/ui';
import { PiggyMark } from '@/components/PiggyMark'; import { AuthShell } from '@/components/AuthShell';
import { usePageTitle } from '@/lib/title'; import { usePageTitle } from '@/lib/title';
export function CreateProfile({ export function CreateProfile({
@@ -51,110 +51,109 @@ export function CreateProfile({
} }
return ( return (
<div className="flex min-h-dvh items-center justify-center bg-bg px-6 py-12"> <AuthShell>
<div className="w-full max-w-md"> <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="mb-6 flex flex-col items-center gap-3 text-center"> <div className="flex flex-col gap-2">
<PiggyMark className="h-12 w-12 text-fg" title="pig" /> <p className="text-xs font-medium uppercase tracking-[0.14em] text-muted">
<div> Private workspace
<h1 className="text-xl font-semibold tracking-tight">Set up your profile</h1> </p>
<p className="mt-1 text-sm text-muted"> <h1 className="text-3xl font-medium tracking-[-0.035em] sm:text-4xl">
You're already authenticated. This creates a PIG profile only after workspace access is verified. Set up your profile
</p> </h1>
</div> <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> </div>
<Card> <form onSubmit={submit} className="mt-6 flex flex-col gap-4">
<CardContent className="pt-5"> <label className="block" htmlFor="profile-name">
<form onSubmit={submit} className="space-y-4"> <span className="mb-1 block text-sm font-medium">Your name</span>
<label className="block" htmlFor="profile-name"> <Input
<span className="mb-1 block text-sm font-medium">Your name</span> id="profile-name"
<Input name="name"
id="profile-name" required
name="name" value={name}
required onChange={(e) => setName(e.target.value)}
value={name} autoComplete="name"
onChange={(e) => setName(e.target.value)} placeholder="Alex Ferguson"
autoComplete="name" />
placeholder="Alex Ferguson" </label>
/>
</label>
<label className="block" htmlFor="profile-job-title"> <label className="block" htmlFor="profile-job-title">
<span className="mb-1 block text-sm font-medium"> <span className="mb-1 block text-sm font-medium">
Title <span className="font-normal text-muted">(optional)</span> Title <span className="font-normal text-muted">(optional)</span>
</span> </span>
<Input <Input
id="profile-job-title" id="profile-job-title"
name="title" name="title"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="Head of Growth" placeholder="Head of Growth"
/> />
</label> </label>
<fieldset> <fieldset>
<legend className="mb-2 text-sm font-medium">Which team are you on?</legend> <legend className="mb-2 text-sm font-medium">Which team are you on?</legend>
<div className="space-y-2"> <div className="flex flex-col gap-2">
{TEAMS.map((value) => ( {TEAMS.map((value) => (
<label <label
key={value} key={value}
className={[ className={[
'flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors', 'flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors',
team === value team === value
? 'border-primary bg-accent-subtle' ? 'border-primary bg-accent-subtle'
: 'border-border hover:bg-surface-2', : 'border-border hover:bg-surface-2',
].join(' ')} ].join(' ')}
> >
<input <input
type="radio" type="radio"
name="team" name="team"
value={value} value={value}
checked={team === value} checked={team === value}
onChange={() => setTeam(value)} onChange={() => setTeam(value)}
className="mt-0.5 h-4 w-4 accent-[hsl(var(--accent))]" 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}
/> />
<span className="mt-1 block text-xs text-muted"> <span className="min-w-0">
Not needed if your address is configured as an administrator. <span className="block text-sm font-medium">{TEAM_LABELS[value]}</span>
<span className="block text-xs text-muted">{TEAM_DESCRIPTIONS[value]}</span>
</span> </span>
</label> </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}> {error ? (
{busy ? 'Creating' : 'Join the workspace'} <p className="text-sm text-danger" role="alert">
</Button> {error}
</form> </p>
</CardContent> ) : null}
</Card>
<Button type="submit" variant="primary" className="w-full" disabled={busy}>
{busy ? 'Creating' : 'Join the workspace'}
</Button>
</form>
</div> </div>
</div> </AuthShell>
); );
} }
+2 -17
View File
@@ -55,8 +55,7 @@ import { usePageTitle } from '@/lib/title';
import { usePiggyContext } from '@/lib/piggy-context'; import { usePiggyContext } from '@/lib/piggy-context';
import { toPiggyPageRoute } from '@pig/core'; import { toPiggyPageRoute } from '@pig/core';
import { Badge, Button, Card, EmptyState, Skeleton } from '@/components/ui'; import { Badge, Button, Card, EmptyState, Skeleton } from '@/components/ui';
import { AudioControl } from '@/components/AudioControl'; import { PublicHeader } from '@/components/PublicHeader';
import { PiggyMark } from '@/components/PiggyMark';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AddResourceDialog } from '@/components/learn/AddResourceDialog'; import { AddResourceDialog } from '@/components/learn/AddResourceDialog';
import { LearnAccessHero } from '@/components/learn/LearnAccessHero'; import { LearnAccessHero } from '@/components/learn/LearnAccessHero';
@@ -414,21 +413,7 @@ function CodeHolderView({
function AnonFrame({ children }: { children: ReactNode }) { function AnonFrame({ children }: { children: ReactNode }) {
return ( return (
<div className="app-canvas flex min-h-dvh min-w-0 flex-col"> <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"> <PublicHeader current="learn" />
<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>
<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"> <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} {children}
</main> </main>
+152 -151
View File
@@ -11,8 +11,8 @@ import { useState } from 'react';
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import { TEAMS, TEAM_DESCRIPTIONS, TEAM_LABELS, type Team } from '@pig/core'; import { TEAMS, TEAM_DESCRIPTIONS, TEAM_LABELS, type Team } from '@pig/core';
import { ApiError, getSupabase, post, type PublicConfig } from '@/lib/api'; import { ApiError, getSupabase, post, type PublicConfig } from '@/lib/api';
import { Button, Card, CardContent, Input } from '@/components/ui'; import { Button, Input } from '@/components/ui';
import { PiggyMark } from '@/components/PiggyMark'; import { AuthShell } from '@/components/AuthShell';
import { usePageTitle } from '@/lib/title'; import { usePageTitle } from '@/lib/title';
export function Register({ export function Register({
@@ -66,9 +66,7 @@ export function Register({
} }
onRegistered(); onRegistered();
} catch (err) { } catch (err) {
setError( setError(err instanceof ApiError ? err.message : 'Could not create your account. Try again.');
err instanceof ApiError ? err.message : 'Could not create your account. Try again.',
);
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -76,160 +74,163 @@ export function Register({
if (!config.canSelfRegister) { if (!config.canSelfRegister) {
return ( return (
<Centered> <AuthShell onSignIn={onBack}>
<Card> <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">
<CardContent className="space-y-3 pt-5 text-center"> <p className="font-medium">Registration is not open here</p>
<p className="font-medium">Registration is not open here</p> <p className="text-sm text-muted">
<p className="text-sm text-muted"> This deployment cannot create accounts on its own. Ask an administrator to provision one
This deployment cannot create accounts on its own. Ask an administrator to for you, then sign in.
provision one for you, then sign in. </p>
</p> <Button variant="outline" onClick={onBack} className="w-full">
<Button variant="outline" onClick={onBack} className="w-full"> Back to sign in
Back to sign in </Button>
</Button> </div>
</CardContent> </AuthShell>
</Card>
</Centered>
); );
} }
return ( return (
<Centered> <AuthShell onSignIn={onBack}>
<div className="mb-6 flex flex-col items-center gap-3 text-center"> <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">
<PiggyMark className="h-12 w-12 text-fg" title="pig" /> <div className="flex flex-col gap-2">
<div> <p className="text-xs font-medium uppercase tracking-[0.14em] text-muted">
<h1 className="text-xl font-semibold tracking-tight">Create your account</h1> Private workspace
<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.
</p> </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. This flow does not enable open registration on the shared identity provider.
</p> </p>
</div> </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> </div>
</AuthShell>
<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>
); );
} }
+144 -149
View File
@@ -12,10 +12,10 @@
* mistakes. * mistakes.
*/ */
import { useState } from 'react'; import { useState } from 'react';
import { KeyRound, Mail } from 'lucide-react'; import { Mail } from 'lucide-react';
import { getSupabase, type PublicConfig } from '@/lib/api'; import { getSupabase, type PublicConfig } from '@/lib/api';
import { Button, Card, CardContent, Input } from '@/components/ui'; import { Button, Input } from '@/components/ui';
import { PiggyMark } from '@/components/PiggyMark'; import { AuthShell } from '@/components/AuthShell';
import { usePageTitle } from '@/lib/title'; import { usePageTitle } from '@/lib/title';
type Method = 'password' | 'link'; type Method = 'password' | 'link';
@@ -75,159 +75,154 @@ export function SignIn({
} }
return ( return (
<div className="flex min-h-dvh items-center justify-center bg-bg px-6 py-12"> <AuthShell>
<div className="w-full max-w-sm"> <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="mb-8 flex flex-col items-center gap-3 text-center"> {status === 'sent' ? (
<PiggyMark className="h-14 w-14 text-fg" title="pig" /> <div className="flex flex-col items-center gap-4 py-4 text-center sm:py-8">
<div> <span className="flex size-12 items-center justify-center rounded-full border border-border bg-surface-2">
<h1 className="text-2xl font-semibold lowercase tracking-tight">pig</h1> <Mail className="size-5 text-accent-fg" aria-hidden />
<p className="text-sm text-muted">Prime Intellect Growth</p> </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>
</div> ) : (
<>
<Card> <div className="flex flex-col gap-2">
<CardContent className="pt-5"> <p className="text-xs font-medium uppercase tracking-[0.14em] text-muted">
{status === 'sent' ? ( Private workspace
<div className="space-y-3 text-center"> </p>
<Mail className="mx-auto h-8 w-8 text-accent-fg" aria-hidden /> <h1 className="text-3xl font-medium tracking-[-0.035em] sm:text-4xl">
<div> Sign in to PIG
<p className="font-medium">Check your email</p> </h1>
<p className="mt-1 text-sm text-muted"> <p className="max-w-md text-sm leading-6 text-muted sm:text-[0.9375rem]">
A sign-in link is on its way to {email}. It expires shortly, so use it Authentication stays with the deployment's identity provider. PIG never stores your
soon. password.
</p> </p>
</div> </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 <button
key={option.key}
type="button" type="button"
onClick={() => setStatus('idle')} role="tab"
className="tap text-sm font-medium text-accent-fg" 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> </button>
</div> ))}
) : ( </div>
<>
<div className="mb-5"> <form onSubmit={submit} className="mt-5 flex flex-col gap-4">
<p className="text-xs font-medium uppercase tracking-[0.14em] text-muted"> <label className="block" htmlFor="sign-in-email">
Private workspace <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> </p>
<h2 className="mt-1 text-lg font-semibold">Sign in to PIG</h2> <button
<p className="mt-1 text-sm text-muted"> type="button"
Authentication stays with the deployment's identity provider. PIG never stores your password. onClick={onCreateAccount}
</p> className="tap -ml-2 inline-flex items-center px-2 text-sm font-medium text-accent-fg"
</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'}
> >
{status === 'busy' Create an account
? method === 'password' </button>
? 'Signing in' </div>
: 'Sending' ) : config.inviteRequired ? (
: method === 'password' <p className="pt-1 text-center text-xs text-muted">
? 'Sign in' This workspace is invite-only. Ask an administrator to provision access; this
: 'Email me a sign-in link'} screen never opens self-registration.
</Button> </p>
) : null}
{status === 'error' ? ( </form>
<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>
</div> </div>
</div> </AuthShell>
); );
} }