diff --git a/src/app/signup/actions.ts b/src/app/signup/actions.ts index 72055f9..7c38d10 100644 --- a/src/app/signup/actions.ts +++ b/src/app/signup/actions.ts @@ -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('//') diff --git a/src/components/Auth/SignupForm.tsx b/src/components/Auth/SignupForm.tsx index 646e6ab..10296a4 100644 --- a/src/components/Auth/SignupForm.tsx +++ b/src/components/Auth/SignupForm.tsx @@ -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(""); const [showPassword, setShowPassword] = React.useState(false); const [emailError, setEmailError] = React.useState(null); const [passwordError, setPasswordError] = React.useState(null); - const [state, formAction] = useActionState(signup, { error: null }); + const [state, formAction, isPending] = useActionState(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 (
{redirectTo && ( )} - {(state?.error) && ( + {(state?.error && !isPending) && (
{state?.error}
)} +
+ + 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 && ( + An invite code is required to create an account. + )} +
Sign up diff --git a/src/utils/supabase/server.ts b/src/utils/supabase/server.ts index 5314470..fa8cd68 100644 --- a/src/utils/supabase/server.ts +++ b/src/utils/supabase/server.ts @@ -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( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SECRET_KEY!, + { + auth: { + persistSession: false, + autoRefreshToken: false, + detectSessionInUrl: false, + } + } + ); +}