diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 1660a04..7d137ca 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -42,6 +42,7 @@ import type { Config } from './lib/config'; import { AuthError, createAuthenticator, type Principal } from './lib/auth'; import { CapacityService } from './services/capacity'; import { createSignupRoute } from './routes/signup'; +import { createRegisterRoute } from './routes/register'; type Env = { Variables: { principal: Principal } }; @@ -69,6 +70,14 @@ export function createApp(config: Config, db: Database) { */ app.route('/', createSignupRoute(config, db)); + /* + * Registration. Also before the auth middleware, and necessarily so: the + * caller has no account yet, so there is no token to present. The invite + * code is the only gate, which is why it is validated before anything is + * created anywhere. + */ + app.route('/', createRegisterRoute(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' })); @@ -83,6 +92,9 @@ export function createApp(config: Config, db: Database) { supabaseAnonKey: config.SUPABASE_ANON_KEY ?? null, authDisabled: !config.SUPABASE_URL, inviteRequired: Boolean(config.PIG_INVITE_CODE), + // Whether someone with an invite can create an account outright, or must + // be provisioned by an administrator first. + canSelfRegister: Boolean(config.SUPABASE_URL && config.SUPABASE_SERVICE_KEY), accents: ACCENTS.map((a) => ({ key: a.key, label: a.label })), teams: TEAMS, }), @@ -91,7 +103,15 @@ export function createApp(config: Config, db: Database) { // Everything below requires a principal. app.use('/api/*', async (c, next) => { const path = new URL(c.req.url).pathname; - if (path === '/api/health' || path === '/api/config' || path === '/api/signup') { + // Public by necessity: health for load balancers, config for the front + // end before sign-in, and the two join routes for people who are not yet + // members. Each verifies whatever it needs itself. + if ( + path === '/api/health' || + path === '/api/config' || + path === '/api/signup' || + path === '/api/register' + ) { return next(); } try { diff --git a/apps/api/src/routes/register.ts b/apps/api/src/routes/register.ts new file mode 100644 index 0000000..dd33a8a --- /dev/null +++ b/apps/api/src/routes/register.ts @@ -0,0 +1,226 @@ +/** + * Registration — create an account and a profile in one step, gated on an + * invite code. + * + * **Why this exists.** PIG shares its identity provider with another + * application, and self-registration there is deliberately switched off. That + * left the invite code unreachable: a new person with a personal address could + * not obtain a token, so they could never get as far as the endpoint that + * accepts the code. The gate was real but nothing could ever reach it. + * + * Opening self-registration on the provider would have opened it for the + * neighbouring application too. So instead PIG mints the account itself, using + * the provider's admin API, and **only after the invite code has been + * validated**. The provider stays closed; the invite becomes the real gate. + * + * The order matters and is deliberate: + * + * 1. Validate the invite. No invite, nothing happens at all. + * 2. Create the identity-provider account. + * 3. Create the PIG profile and team membership. + * 4. Consume the invite. + * + * If step 3 fails the provider account is removed again, so a half-registered + * person cannot exist — able to sign in, but with no profile and no way to get + * one because the invite is spent. + */ +import { Hono } from 'hono'; +import { and, eq, gt, 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({ + email: z.string().email().max(200), + // Eight is the floor. The provider's own default is six, which is too short + // for an account that reaches commercial data. + password: z.string().min(8, 'Use at least 8 characters.').max(200), + name: z.string().min(1).max(120), + team: z.enum(TEAMS), + inviteCode: z.string().min(1).max(200), + title: z.string().max(160).optional(), +}); + +export function createRegisterRoute(config: Config, db: Database) { + const app = new Hono(); + + app.post('/api/register', async (c) => { + if (!config.SUPABASE_URL || !config.SUPABASE_SERVICE_KEY) { + // Without the admin credential PIG cannot mint accounts. Say so plainly + // rather than failing in a way that looks like the invite is wrong. + return c.json( + { + error: + 'Self-registration is not configured on this deployment. Ask an ' + + 'administrator to create your account.', + code: 'registration_unavailable', + }, + 503, + ); + } + + const parsed = bodySchema.safeParse(await c.req.json().catch(() => ({}))); + if (!parsed.success) { + return c.json( + { error: parsed.error.issues[0]?.message ?? 'Invalid details', code: 'invalid_body' }, + 400, + ); + } + + const email = parsed.data.email.trim().toLowerCase(); + + // --- 1. The invite, before anything is created anywhere ---------------- + // + // 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 we are creating an account from nothing, and an + // ungated version of that is an open registration endpoint. + 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), + gt(invites.usesRemaining, 0), + ), + ) + .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); + } + if (invite.email && invite.email.toLowerCase() !== email) { + return c.json( + { error: 'That invite code was issued to a different address.', code: 'invite_mismatch' }, + 403, + ); + } + + // Already a PIG member? Send them to sign in rather than creating a second + // account they cannot use. + const [existingProfile] = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.email, email)) + .limit(1); + + if (existingProfile) { + return c.json( + { error: 'That address is already a member. Sign in instead.', code: 'already_member' }, + 409, + ); + } + + // --- 2. The identity-provider account --------------------------------- + const adminHeaders = { + apikey: config.SUPABASE_SERVICE_KEY, + authorization: `Bearer ${config.SUPABASE_SERVICE_KEY}`, + 'content-type': 'application/json', + }; + + let authSubject: string; + try { + const response = await fetch(`${config.SUPABASE_URL}/auth/v1/admin/users`, { + method: 'POST', + headers: adminHeaders, + body: JSON.stringify({ + email, + password: parsed.data.password, + // Confirmed on creation: the invite already proved this person was + // vouched for, and requiring a second confirmation email would strand + // anyone whose address cannot receive mail from this project. + email_confirm: true, + }), + }); + + if (!response.ok) { + const detail = await response.text().catch(() => ''); + // An address that already exists in the provider but has no PIG + // profile is a real case — someone with an account from the sibling + // application. Direct them to sign in and complete a profile, which + // /api/signup handles. + if (response.status === 422 || detail.includes('already been registered')) { + return c.json( + { + error: + 'An account already exists for that address. Sign in with it, and ' + + 'you will be asked to set up your PIG profile.', + code: 'account_exists', + }, + 409, + ); + } + console.error('[pig] provider rejected account creation:', response.status, detail.slice(0, 200)); + return c.json({ error: 'Could not create the account.', code: 'provider_error' }, 502); + } + + const created = (await response.json()) as { id?: string }; + if (!created.id) throw new Error('provider returned no user id'); + authSubject = created.id; + } catch (error) { + console.error('[pig] account creation failed:', error); + return c.json({ error: 'Could not create the account.', code: 'provider_error' }, 502); + } + + // --- 3. The PIG profile ------------------------------------------------ + try { + const [user] = await db + .insert(users) + .values({ + authSubject, + email, + name: parsed.data.name, + title: parsed.data.title, + // Admin rights come only from server configuration, never from a + // registration payload. + isPlatformAdmin: config.adminEmails.includes(email), + }) + .returning(); + + if (!user) throw new Error('profile insert returned nothing'); + + await db + .insert(teamMemberships) + .values({ + userId: user.id, + team: parsed.data.team, + role: config.adminEmails.includes(email) ? 'admin' : 'member', + isPrimary: true, + }) + .onConflictDoNothing(); + + // --- 4. Consume the invite ------------------------------------------- + await db + .update(invites) + .set({ + redeemedByUserId: user.id, + redeemedAt: new Date(), + usesRemaining: sql`GREATEST(0, ${invites.usesRemaining} - 1)`, + }) + .where(eq(invites.id, invite.id)); + + return c.json({ ok: true, email: user.email }, 201); + } catch (error) { + // Roll the provider account back. Without this, the person could sign in + // but would have no profile and no way to obtain one, because their + // invite would look spent — a genuinely stuck state. + console.error('[pig] profile creation failed, removing the auth account:', error); + await fetch(`${config.SUPABASE_URL}/auth/v1/admin/users/${authSubject}`, { + method: 'DELETE', + headers: adminHeaders, + }).catch(() => {}); + return c.json({ error: 'Could not complete registration.', code: 'profile_error' }, 500); + } + }); + + return app; +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3de2721..11ab6ce 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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 ; if (error instanceof ApiError) { - if (error.needsSignIn) return ; + if (error.needsSignIn) { + return showRegister ? ( + setShowRegister(false)} + onRegistered={() => { + setShowRegister(false); + void refetch(); + }} + /> + ) : ( + 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. diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 3f30d64..4f1d078 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -15,6 +15,7 @@ export interface PublicConfig { supabaseAnonKey: string | null; authDisabled: boolean; inviteRequired: boolean; + canSelfRegister: boolean; accents: { key: string; label: string }[]; teams: string[]; } diff --git a/apps/web/src/pages/Register.tsx b/apps/web/src/pages/Register.tsx new file mode 100644 index 0000000..2a35b37 --- /dev/null +++ b/apps/web/src/pages/Register.tsx @@ -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('demand'); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const set = (key: keyof typeof form) => (e: React.ChangeEvent) => + 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 ( + + + +

Registration is not open here

+

+ This deployment cannot create accounts on its own. Ask an administrator to + provision one for you, then sign in. +

+ +
+
+
+ ); + } + + return ( + +
+ +
+

Create your account

+

+ Use any email you like — your invite code is what grants access. +

+
+
+ + + +
+ + + + + + + + + + +
+ Which team are you on? +
+ {TEAMS.map((value) => ( + + ))} +
+
+ + {error ? ( +

+ {error} +

+ ) : null} + + + + +
+
+
+
+ ); +} + +function Centered({ children }: { children: React.ReactNode }) { + return ( +
+
{children}
+
+ ); +} diff --git a/apps/web/src/pages/SignIn.tsx b/apps/web/src/pages/SignIn.tsx index c6ec5da..94ad2e5 100644 --- a/apps/web/src/pages/SignIn.tsx +++ b/apps/web/src/pages/SignIn.tsx @@ -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('password'); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); @@ -183,7 +189,20 @@ export function SignIn({ config }: { config: PublicConfig }) {

) : null} - {config.inviteRequired ? ( + {config.canSelfRegister ? ( +
+

+ Have an invite code but no account? +

+ +
+ ) : config.inviteRequired ? (

PIG is invite-only. An account alone does not grant access.