Add profile creation — close the gap between signing in and being a member
Deploying and then trying to actually use it surfaced a dead end: /api/signup was exempted from auth but never implemented, so a real person could sign in, receive 403 needs_profile, and have nowhere to go. Authentication worked; joining did not. The route is mounted before the auth middleware, because requiring membership to reach the route that grants membership is circular. It verifies the token itself and then requires one of two things: - A valid invite code. Stored hashed, optionally pinned to an address, optionally expiring, consumed on redemption with the redeemer recorded. - Presence in PIG_ADMIN_EMAILS. The bootstrap path, which exists because on a fresh deployment nobody can issue an invite since nobody can sign in to issue one. The bootstrap path is narrow by construction: the address must be listed in server-side configuration AND match the verified email claim on the token. Admin rights are never read from the request body, so a crafted payload cannot grant them. Two behaviours worth noting. A row that was invited but never signed into is claimed rather than rejected, binding it to the identity that just proved ownership of the address. And a resubmitted form returns the existing user instead of erroring, because a double-tap should be harmless. The front end now treats needs_profile as a step in the flow rather than an error, showing a team picker. Sending someone back to a login screen they have already completed is a loop with no exit. Also: the seed no longer creates the dev@localhost admin row under NODE_ENV=production. It was unreachable (no auth subject, so nobody can sign in as it), but an admin-flagged placeholder in a real deployment is a trap. Verified: rejects a missing token, rejects an invalid body, claims a pre-existing row, and is idempotent on resubmission. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,7 @@ import {
|
||||
import type { Config } from './lib/config';
|
||||
import { AuthError, createAuthenticator, type Principal } from './lib/auth';
|
||||
import { CapacityService } from './services/capacity';
|
||||
import { createSignupRoute } from './routes/signup';
|
||||
|
||||
type Env = { Variables: { principal: Principal } };
|
||||
|
||||
@@ -61,6 +62,13 @@ export function createApp(config: Config, db: Database) {
|
||||
}),
|
||||
);
|
||||
|
||||
/*
|
||||
* Profile creation. Mounted BEFORE the auth middleware because it is the
|
||||
* route that turns an authenticated stranger into a member — requiring
|
||||
* membership to reach it would be circular. It verifies the token itself.
|
||||
*/
|
||||
app.route('/', createSignupRoute(config, db));
|
||||
|
||||
/** Liveness. Unauthenticated by design so a load balancer can reach it. */
|
||||
app.get('/api/health', (c) => c.json({ ok: true, service: 'pig', version: '0.1.0' }));
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Profile creation — how an authenticated person becomes a PIG user.
|
||||
*
|
||||
* This closes the gap between the two halves of access control. Signing in
|
||||
* with the identity provider proves who someone is; it does not make them a
|
||||
* member of this workspace. This route is the deliberate step in between,
|
||||
* and it is the only way a `users` row is created for a real person.
|
||||
*
|
||||
* Two ways to pass:
|
||||
*
|
||||
* 1. **A valid invite code.** The normal path. Codes are stored hashed, can
|
||||
* be pinned to an address, can expire, and are consumed on redemption.
|
||||
*
|
||||
* 2. **Being listed in `PIG_ADMIN_EMAILS`.** The bootstrap path, which
|
||||
* exists to solve the first-administrator problem: on a fresh deployment
|
||||
* nobody can issue an invite because nobody can sign in to issue one.
|
||||
*
|
||||
* The bootstrap path is the dangerous one, so it is narrow by construction:
|
||||
* the address must be listed in configuration by whoever controls the server,
|
||||
* AND must match the verified `email` claim on the token. Listing an address
|
||||
* nobody owns is a standing offer of admin rights to whoever registers it
|
||||
* first — which is why the deployment docs say every entry must already have
|
||||
* an account, and why the server warns at boot.
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||
import { and, eq, isNull, or, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Database } from '@pig/db';
|
||||
import { invites, teamMemberships, users } from '@pig/db';
|
||||
import { TEAMS } from '@pig/core';
|
||||
import type { Config } from '../lib/config';
|
||||
|
||||
const bodySchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
team: z.enum(TEAMS),
|
||||
inviteCode: z.string().min(1).max(200).optional(),
|
||||
title: z.string().max(160).optional(),
|
||||
});
|
||||
|
||||
export function createSignupRoute(config: Config, db: Database) {
|
||||
const app = new Hono();
|
||||
|
||||
const jwks = config.SUPABASE_URL
|
||||
? createRemoteJWKSet(new URL(`${config.SUPABASE_URL}/auth/v1/.well-known/jwks.json`))
|
||||
: null;
|
||||
|
||||
app.post('/api/signup', async (c) => {
|
||||
const parsed = bodySchema.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!parsed.success) {
|
||||
return c.json({ error: 'Invalid profile', issues: parsed.error.issues }, 400);
|
||||
}
|
||||
|
||||
// Identity must be proven before anything else is considered.
|
||||
const header = c.req.header('authorization');
|
||||
if (!header?.startsWith('Bearer ')) {
|
||||
return c.json({ error: 'Sign in first.', code: 'no_token' }, 401);
|
||||
}
|
||||
|
||||
let subject: string;
|
||||
let email: string;
|
||||
|
||||
if (!config.SUPABASE_URL) {
|
||||
// Development only. `loadConfig` refuses to start in production without
|
||||
// an identity provider, so this branch cannot exist in a real deployment.
|
||||
if (config.isProduction) {
|
||||
return c.json({ error: 'Authentication is not configured.' }, 500);
|
||||
}
|
||||
subject = '00000000-0000-0000-0000-000000000000';
|
||||
email = 'dev@localhost';
|
||||
} else {
|
||||
try {
|
||||
const { payload } = await jwtVerify(header.slice(7).trim(), jwks!, {
|
||||
issuer: `${config.SUPABASE_URL}/auth/v1`,
|
||||
});
|
||||
if (!payload.sub || typeof payload.email !== 'string') {
|
||||
throw new Error('token missing subject or email');
|
||||
}
|
||||
subject = payload.sub;
|
||||
email = payload.email.toLowerCase();
|
||||
} catch {
|
||||
return c.json({ error: 'Invalid or expired token.', code: 'invalid_token' }, 401);
|
||||
}
|
||||
}
|
||||
|
||||
// Already a member? Return the existing row rather than erroring — a
|
||||
// double-submitted form should be harmless.
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(or(eq(users.authSubject, subject), eq(users.email, email)))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
// An invited-but-never-signed-in row is claimed here, binding it to the
|
||||
// identity that just proved ownership of the address.
|
||||
if (!existing.authSubject) {
|
||||
const [claimed] = await db
|
||||
.update(users)
|
||||
.set({ authSubject: subject, updatedAt: new Date() })
|
||||
.where(eq(users.id, existing.id))
|
||||
.returning();
|
||||
return c.json({ user: claimed, claimed: true });
|
||||
}
|
||||
return c.json({ user: existing, alreadyMember: true });
|
||||
}
|
||||
|
||||
const isBootstrapAdmin = config.adminEmails.includes(email);
|
||||
|
||||
// Resolve the invite before writing anything, so a failed redemption
|
||||
// leaves no partial user behind.
|
||||
let inviteId: string | null = null;
|
||||
if (!isBootstrapAdmin) {
|
||||
if (!parsed.data.inviteCode) {
|
||||
return c.json(
|
||||
{ error: 'An invite code is required to join this workspace.', code: 'invite_required' },
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
const codeHash = createHash('sha256')
|
||||
.update(parsed.data.inviteCode.trim())
|
||||
.digest('hex');
|
||||
|
||||
const [invite] = await db
|
||||
.select()
|
||||
.from(invites)
|
||||
.where(
|
||||
and(
|
||||
eq(invites.codeHash, codeHash),
|
||||
isNull(invites.revokedAt),
|
||||
isNull(invites.redeemedAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!invite) {
|
||||
return c.json({ error: 'That invite code is not valid.', code: 'invalid_invite' }, 403);
|
||||
}
|
||||
if (invite.expiresAt && invite.expiresAt < new Date()) {
|
||||
return c.json({ error: 'That invite code has expired.', code: 'expired_invite' }, 403);
|
||||
}
|
||||
// A pinned invite may only be redeemed by the address it names.
|
||||
if (invite.email && invite.email.toLowerCase() !== email) {
|
||||
return c.json(
|
||||
{ error: 'That invite code was issued to a different address.', code: 'invite_mismatch' },
|
||||
403,
|
||||
);
|
||||
}
|
||||
inviteId = invite.id;
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
authSubject: subject,
|
||||
email,
|
||||
name: parsed.data.name,
|
||||
title: parsed.data.title,
|
||||
// Admin rights are NOT taken from the request. They come from the
|
||||
// server's own configuration, so a crafted payload cannot grant them.
|
||||
isPlatformAdmin: isBootstrapAdmin,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!created) return c.json({ error: 'Could not create the profile.' }, 500);
|
||||
|
||||
await db
|
||||
.insert(teamMemberships)
|
||||
.values({
|
||||
userId: created.id,
|
||||
team: parsed.data.team,
|
||||
role: isBootstrapAdmin ? 'admin' : 'member',
|
||||
isPrimary: true,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
if (inviteId) {
|
||||
// Consume the invite. Decrementing rather than deleting keeps the audit
|
||||
// trail of who redeemed what.
|
||||
await db
|
||||
.update(invites)
|
||||
.set({
|
||||
redeemedByUserId: created.id,
|
||||
redeemedAt: new Date(),
|
||||
usesRemaining: sql`GREATEST(0, (${invites.usesRemaining})::int - 1)`,
|
||||
})
|
||||
.where(eq(invites.id, inviteId));
|
||||
}
|
||||
|
||||
return c.json({ user: created, bootstrapAdmin: isBootstrapAdmin }, 201);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { Settings } from '@/pages/Settings';
|
||||
import { Accounts } from '@/pages/Accounts';
|
||||
import { Margin } from '@/pages/Margin';
|
||||
import { SignIn } from '@/pages/SignIn';
|
||||
import { CreateProfile } from '@/pages/CreateProfile';
|
||||
import { PiggyMark } from '@/components/PiggyMark';
|
||||
import { EmptyState } from '@/components/ui';
|
||||
|
||||
@@ -115,15 +116,11 @@ function AuthGate({ config }: { config: PublicConfig }) {
|
||||
|
||||
if (error instanceof ApiError) {
|
||||
if (error.needsSignIn) return <SignIn config={config} />;
|
||||
// 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.
|
||||
if (error.needsProfile) {
|
||||
return (
|
||||
<Centered>
|
||||
<EmptyState
|
||||
title="You're signed in, but not a member of this workspace"
|
||||
description="PIG uses a shared identity provider, so having an account is not the same as having access here. Ask an administrator for an invite."
|
||||
/>
|
||||
</Centered>
|
||||
);
|
||||
return <CreateProfile config={config} onCreated={() => void refetch()} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Profile creation.
|
||||
*
|
||||
* Shown when someone is authenticated but is not yet a member — the state that
|
||||
* exists precisely because PIG shares its identity provider with other
|
||||
* applications. Without this screen that state is a dead end, which is a
|
||||
* miserable thing to hand someone who has just clicked a sign-in link.
|
||||
*/
|
||||
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';
|
||||
|
||||
export function CreateProfile({
|
||||
config,
|
||||
onCreated,
|
||||
}: {
|
||||
config: PublicConfig;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
const [title, setTitle] = useState('');
|
||||
const [team, setTeam] = useState<Team>('demand');
|
||||
const [inviteCode, setInviteCode] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await post('/api/signup', {
|
||||
name,
|
||||
team,
|
||||
title: title || undefined,
|
||||
inviteCode: inviteCode || undefined,
|
||||
});
|
||||
onCreated();
|
||||
} catch (err) {
|
||||
// The server distinguishes "no code", "wrong code", "expired" and
|
||||
// "issued to someone else". All four are actionable, so all four are
|
||||
// shown rather than flattened into "access denied".
|
||||
setError(err instanceof ApiError ? err.message : 'Could not create your profile.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
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 signed in. One more step to join the workspace.
|
||||
</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">Your name</span>
|
||||
<Input
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoComplete="name"
|
||||
placeholder="Alex Ferguson"
|
||||
/>
|
||||
</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={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-accent 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">
|
||||
<span className="mb-1 block text-sm font-medium">Invite code</span>
|
||||
<Input
|
||||
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}
|
||||
|
||||
{error ? <p className="text-sm text-danger">{error}</p> : null}
|
||||
|
||||
<Button type="submit" variant="primary" className="w-full" disabled={busy}>
|
||||
{busy ? 'Creating…' : 'Join the workspace'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user