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:
2026-08-12 20:15:32 -07:00
parent 045e51bc5c
commit 7719850fc5
6 changed files with 514 additions and 4 deletions
+19 -1
View File
@@ -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.