Require invite code to create account

This commit is contained in:
Jared Schoeny 2025-10-21 13:44:02 -10:00
parent dd8a1013ba
commit be8a073329
3 changed files with 88 additions and 6 deletions

View File

@ -4,7 +4,7 @@ import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { AuthError } from '@supabase/supabase-js'
import { createClient } from '@/utils/supabase/server'
import { createClient, createServiceClient } from '@/utils/supabase/server'
import { validateEmail, validatePassword } from '@/utils/auth'
function getErrorMessage(error: AuthError): string {
@ -32,11 +32,13 @@ export interface AuthActionState {
export async function signup(state: AuthActionState, payload: FormData) {
const supabase = await createClient()
const service = await createServiceClient()
const data = {
email: payload.get('email') as string,
password: payload.get('password') as string,
}
const inviteCode = (payload.get('inviteCode') as string | null)?.trim() || ''
const { error: emailError } = validateEmail(data.email);
if (emailError) {
@ -48,12 +50,48 @@ export async function signup(state: AuthActionState, payload: FormData) {
return { error: passwordError };
}
const { error } = await supabase.auth.signUp(data)
if (!inviteCode) {
return { error: 'An invite code is required to sign up.' }
}
// Pre-check: ensure invite exists and is unused before attempting signup
const { data: availableInvite, error: inviteCheckError } = await service
.from('invite_codes')
.select('code')
.eq('code', inviteCode)
.is('used_by', null)
.maybeSingle()
if (inviteCheckError || !availableInvite) {
return { error: 'Invalid or already used invite code.' }
}
const { data: signUpResult, error } = await supabase.auth.signUp(data)
if (error) {
return { error: getErrorMessage(error) };
}
const userId = signUpResult.user?.id || null
// Finalize: set used_by to the new user id iff still unused (atomic)
const { data: finalized, error: finalizeError } = await service
.from('invite_codes')
.update({ used_by: userId ?? null })
.eq('code', inviteCode)
.is('used_by', null)
.select('code')
.maybeSingle()
if (finalizeError || !finalized) {
// The code claim could not be finalized (race). Roll back user creation.
if (userId) {
try {
await service.auth.admin.deleteUser(userId)
} catch {}
}
return { error: 'Invite code is no longer available. Please try again.' }
}
revalidatePath('/', 'layout');
const redirectTo = (payload.get('redirectTo') as string | null) || null
const isValidInternalPath = redirectTo && redirectTo.startsWith('/') && !redirectTo.startsWith('//')

View File

@ -11,13 +11,14 @@ export default function SignupForm() {
const [email, setEmail] = React.useState("");
const [password, setPassword] = React.useState("");
const [confirm, setConfirm] = React.useState("");
const [invite, setInvite] = React.useState<string>("");
const [showPassword, setShowPassword] = React.useState(false);
const [emailError, setEmailError] = React.useState<string | null>(null);
const [passwordError, setPasswordError] = React.useState<string | null>(null);
const [state, formAction] = useActionState<AuthActionState, FormData>(signup, { error: null });
const [state, formAction, isPending] = useActionState<AuthActionState, FormData>(signup, { error: null });
const passwordsMatch = password === confirm;
const isValid = !emailError && !passwordError && passwordsMatch;
const isValid = !emailError && !passwordError && passwordsMatch && Boolean(invite);
useEffect(() => {
const { error } = validateEmail(email);
@ -31,16 +32,44 @@ export default function SignupForm() {
const redirectTo = searchParams.get("redirectTo");
useEffect(() => {
const inviteFromParams = searchParams.get("invite") || "";
if (inviteFromParams) {
setInvite(inviteFromParams);
}
}, []);
return (
<form className="grid gap-5 group">
{redirectTo && (
<input type="hidden" name="redirectTo" value={redirectTo} />
)}
{(state?.error) && (
{(state?.error && !isPending) && (
<div className="rounded-md bg-red-500/10 ring-1 ring-red-600/40 px-3 py-2 text-sm text-red-300">
{state?.error}
</div>
)}
<div className="grid gap-2">
<label htmlFor="inviteCode" className="text-sm text-foreground/80">Invite code</label>
<input
id="inviteCode"
name="inviteCode"
type="text"
value={invite}
onChange={(e) => setInvite(e.target.value)}
placeholder="Enter your invite code"
className={`h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)] ${
invite ? "bg-[var(--surface-2)] ring-[var(--border)]" : "bg-[var(--surface-2)] ring-[var(--border)]"
}`}
required
inputMode="text"
autoComplete="off"
spellCheck={false}
/>
{!invite && (
<span className="text-xs text-foreground/60">An invite code is required to create an account.</span>
)}
</div>
<div className="grid gap-2">
<label htmlFor="email" className="text-sm text-foreground/80">Email</label>
<input
@ -130,7 +159,7 @@ export default function SignupForm() {
<button
type="submit"
formAction={formAction}
disabled={!isValid}
disabled={!isValid || isPending}
className="shine-wrap btn-premium h-11 min-w-[7.5rem] text-sm font-semibold hover:cursor-pointer dark:disabled:opacity-70 disabled:cursor-not-allowed disabled:[box-shadow:0_0_0_1px_var(--border)]"
>
<span>Sign up</span>

View File

@ -1,4 +1,5 @@
import { createServerClient } from "@supabase/ssr";
import { createClient as createSupabaseClient } from "@supabase/supabase-js";
import { cookies } from "next/headers";
import { Database } from "@/types/db";
@ -30,3 +31,17 @@ export async function createClient() {
}
);
}
export async function createServiceClient() {
return createSupabaseClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SECRET_KEY!,
{
auth: {
persistSession: false,
autoRefreshToken: false,
detectSessionInUrl: false,
}
}
);
}