Polish the public auth experience
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:
+4
-16
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user