Let people register with a personal email and an invite code
The invite gate was unreachable. PIG shares its identity provider with another application, and self-registration there is deliberately switched off — so somebody with a personal address and a valid invite code could never obtain a token, and therefore could never reach the endpoint that accepts the code. The gate was real and nothing could ever arrive at it. Opening self-registration on the provider would have opened it for the neighbouring application too, which is precisely why it was closed. So PIG now mints the account itself through the provider's admin API, and only after the invite validates. The provider stays shut; the invite becomes the actual gate. Order is deliberate: validate the invite, create the auth account, create the profile, consume the invite. If the profile write fails the auth account is deleted again — otherwise someone could sign in with no profile and no way to obtain one, because their invite would look spent. An administrator's address does NOT bypass this. The bypass in /api/signup exists to bootstrap the first admin from an account that already exists; here an account is created from nothing, and an ungated version of that is simply an open registration endpoint. Registration signs the person in on success rather than returning them to a login form to retype the password they entered ten seconds earlier. An address that already exists in the provider but has no PIG profile is detected and pointed at sign-in, since /api/signup handles that case properly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+19
-1
@@ -15,6 +15,7 @@ import { Accounts } from '@/pages/Accounts';
|
||||
import { Margin } from '@/pages/Margin';
|
||||
import { SignIn } from '@/pages/SignIn';
|
||||
import { CreateProfile } from '@/pages/CreateProfile';
|
||||
import { Register } from '@/pages/Register';
|
||||
import { PiggyMark } from '@/components/PiggyMark';
|
||||
import { EmptyState } from '@/components/ui';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
@@ -86,6 +87,10 @@ export function App() {
|
||||
* the default behaviour if both are treated as "auth error".
|
||||
*/
|
||||
function AuthGate({ config }: { config: PublicConfig }) {
|
||||
// Which unauthenticated screen to show. Kept in state rather than a route so
|
||||
// that a half-filled registration form is not lost to an accidental Back.
|
||||
const [showRegister, setShowRegister] = useState(false);
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['me'],
|
||||
queryFn: () => get<{ id: string; name: string }>('/api/me'),
|
||||
@@ -116,7 +121,20 @@ function AuthGate({ config }: { config: PublicConfig }) {
|
||||
if (isLoading) return <Splash />;
|
||||
|
||||
if (error instanceof ApiError) {
|
||||
if (error.needsSignIn) return <SignIn config={config} />;
|
||||
if (error.needsSignIn) {
|
||||
return showRegister ? (
|
||||
<Register
|
||||
config={config}
|
||||
onBack={() => setShowRegister(false)}
|
||||
onRegistered={() => {
|
||||
setShowRegister(false);
|
||||
void refetch();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<SignIn config={config} onCreateAccount={() => setShowRegister(true)} />
|
||||
);
|
||||
}
|
||||
// Authenticated but not a member. This is a step in the flow, not an
|
||||
// error — sending them back to a login screen they have already completed
|
||||
// would be a loop with no exit.
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface PublicConfig {
|
||||
supabaseAnonKey: string | null;
|
||||
authDisabled: boolean;
|
||||
inviteRequired: boolean;
|
||||
canSelfRegister: boolean;
|
||||
accents: { key: string; label: string }[];
|
||||
teams: string[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Create an account.
|
||||
*
|
||||
* For someone who has an invite code but no account at all — the common case,
|
||||
* because the identity provider's own self-registration is deliberately shut.
|
||||
* PIG mints the account server-side once the invite validates, then signs the
|
||||
* person straight in so they land on the dashboard rather than being returned
|
||||
* to a login form having just typed their password.
|
||||
*/
|
||||
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 { usePageTitle } from '@/lib/title';
|
||||
|
||||
export function Register({
|
||||
config,
|
||||
onBack,
|
||||
onRegistered,
|
||||
}: {
|
||||
config: PublicConfig;
|
||||
onBack: () => void;
|
||||
onRegistered: () => void;
|
||||
}) {
|
||||
usePageTitle('Create account');
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
inviteCode: '',
|
||||
title: '',
|
||||
});
|
||||
const [team, setTeam] = useState<Team>('demand');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const set = (key: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setForm((f) => ({ ...f, [key]: e.target.value }));
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await post('/api/register', {
|
||||
email: form.email.trim(),
|
||||
password: form.password,
|
||||
name: form.name.trim(),
|
||||
team,
|
||||
inviteCode: form.inviteCode.trim(),
|
||||
title: form.title.trim() || undefined,
|
||||
});
|
||||
|
||||
// Sign in immediately. Making someone re-enter the password they typed
|
||||
// ten seconds ago reads as though registration silently failed.
|
||||
const supabase = getSupabase();
|
||||
if (supabase) {
|
||||
await supabase.auth.signInWithPassword({
|
||||
email: form.email.trim(),
|
||||
password: form.password,
|
||||
});
|
||||
}
|
||||
onRegistered();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : 'Could not create your account. Try again.',
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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 any email you like — your invite code is what grants access.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium">Invite code</span>
|
||||
<Input
|
||||
required
|
||||
value={form.inviteCode}
|
||||
onChange={set('inviteCode')}
|
||||
placeholder="Ask whoever invited you"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium">Your name</span>
|
||||
<Input required value={form.name} onChange={set('name')} autoComplete="name" />
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium">Email</span>
|
||||
<Input
|
||||
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">
|
||||
<span className="mb-1 block text-sm font-medium">Password</span>
|
||||
<Input
|
||||
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">
|
||||
<span className="mb-1 block text-sm font-medium">
|
||||
Title <span className="font-normal text-muted">(optional)</span>
|
||||
</span>
|
||||
<Input 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-accent 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>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,13 @@ import { PiggyMark } from '@/components/PiggyMark';
|
||||
|
||||
type Method = 'password' | 'link';
|
||||
|
||||
export function SignIn({ config }: { config: PublicConfig }) {
|
||||
export function SignIn({
|
||||
config,
|
||||
onCreateAccount,
|
||||
}: {
|
||||
config: PublicConfig;
|
||||
onCreateAccount: () => void;
|
||||
}) {
|
||||
const [method, setMethod] = useState<Method>('password');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -183,7 +189,20 @@ export function SignIn({ config }: { config: PublicConfig }) {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{config.inviteRequired ? (
|
||||
{config.canSelfRegister ? (
|
||||
<div className="border-t border-border pt-3 text-center">
|
||||
<p className="text-sm text-muted">
|
||||
Have an invite code but no account?
|
||||
</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">
|
||||
PIG is invite-only. An account alone does not grant access.
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user