Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions apps/web/src/app/login/auth-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export const INVITATION_REQUIRED_MESSAGE =
"You need a team invitation to create an account on this instance.";
export const GENERIC_AUTH_ERROR_MESSAGE =
"Unable to sign in. Please try again.";

export function getAuthErrorMessage(error?: string | null) {
if (!error) {
return null;
}

if (error === "AccessDenied") {
return INVITATION_REQUIRED_MESSAGE;
}

return GENERIC_AUTH_ERROR_MESSAGE;
}
56 changes: 46 additions & 10 deletions apps/web/src/app/login/login-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ import { Input } from "@usesend/ui/src/input";
import { BuiltInProviderType } from "next-auth/providers/index";
import Spinner from "@usesend/ui/src/spinner";
import Link from "next/link";
import { useTheme } from "@usesend/ui";
import { useSearchParams as useNextSearchParams } from "next/navigation";
import { GENERIC_AUTH_ERROR_MESSAGE, getAuthErrorMessage } from "./auth-error";

const emailSchema = z.object({
email: z
Expand Down Expand Up @@ -82,11 +82,40 @@ export default function LoginPage({

async function onEmailSubmit(values: z.infer<typeof emailSchema>) {
setEmailStatus("sending");
await signIn("email", {
email: values.email.toLowerCase(),
redirect: false,
});
setEmailStatus("success");
emailForm.clearErrors("email");

try {
const result = await signIn("email", {
email: values.email.toLowerCase(),
redirect: false,
});

if (!result || result.error) {
setEmailStatus("idle");
emailForm.setError(
"email",
{
type: "server",
message:
getAuthErrorMessage(result?.error) ?? GENERIC_AUTH_ERROR_MESSAGE,
},
{ shouldFocus: true },
);
return;
}

setEmailStatus("success");
} catch {
setEmailStatus("idle");
emailForm.setError(
"email",
{
type: "server",
message: GENERIC_AUTH_ERROR_MESSAGE,
},
{ shouldFocus: true },
);
}
}

async function onOTPSubmit(values: z.infer<typeof otpSchema>) {
Expand All @@ -98,19 +127,20 @@ export default function LoginPage({
? `/join-team?inviteId=${inviteId}`
: `${callbackUrl}/dashboard`;
window.location.href = `/api/auth/callback/email?email=${encodeURIComponent(
email.toLowerCase()
email.toLowerCase(),
)}&token=${values.otp.toLowerCase()}&callbackUrl=${encodeURIComponent(finalCallbackUrl)}`;
}

const emailProvider = providers?.find(
(provider) => provider.type === "email"
(provider) => provider.type === "email",
);

const [submittedProvider, setSubmittedProvider] =
useState<LiteralUnion<BuiltInProviderType> | null>(null);

const searchParams = useNextSearchParams();
const inviteId = searchParams.get("inviteId");
const authErrorMessage = getAuthErrorMessage(searchParams.get("error"));

const handleSubmit = (provider: LiteralUnion<BuiltInProviderType>) => {
setSubmittedProvider(provider);
Expand All @@ -120,8 +150,6 @@ export default function LoginPage({
signIn(provider, { callbackUrl });
};

const { resolvedTheme } = useTheme();

return (
<main className="h-screen flex justify-center items-center">
<div className="flex flex-col gap-6">
Expand All @@ -148,6 +176,14 @@ export default function LoginPage({
</div>

<div className="flex flex-col gap-8 mt-8 border p-8 rounded-lg shadow">
{authErrorMessage ? (
<p
role="alert"
className="w-[350px] text-center text-sm text-destructive"
>
{authErrorMessage}
</p>
) : null}
{providers &&
Object.values(providers).map((provider) => {
if (provider.type === "email") return null;
Expand Down
113 changes: 108 additions & 5 deletions apps/web/src/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
type DefaultSession,
type NextAuthOptions,
} from "next-auth";
import { type Adapter } from "next-auth/adapters";
import { type Adapter, type AdapterUser } from "next-auth/adapters";
import GitHubProvider from "next-auth/providers/github";
import EmailProvider from "next-auth/providers/email";
import GoogleProvider from "next-auth/providers/google";
Expand All @@ -16,6 +16,58 @@ import { db } from "~/server/db";

const GITHUB_OAUTH_ISSUER = "https://github.com/login/oauth";

/**
* PostgreSQL advisory-lock namespace for self-hosted user creation.
*
* The lock serializes only transactions that request this same key; it does not
* lock the User table or any rows. Because pg_advisory_xact_lock is scoped to
* the current transaction, PostgreSQL releases it automatically on commit,
* rollback, or connection loss. A concurrent registration may wait briefly for
* the active registration transaction to finish.
*/
const SELF_HOSTED_REGISTRATION_LOCK_ID = 1431520590;

export class SelfHostedRegistrationError extends Error {
constructor() {
super("A team invitation is required to create an account");
this.name = "SelfHostedRegistrationError";
}
}

export async function canRegisterSelfHostedUser(email?: string | null) {
if (env.NEXT_PUBLIC_IS_CLOUD) {
return true;
}

if (!email) {
return false;
}

const existingUser = await db.user.findUnique({
where: { email },
select: { id: true },
});

if (existingUser) {
return true;
}

const firstUser = await db.user.findFirst({
select: { id: true },
});

if (!firstUser) {
return true;
}

const invite = await db.teamInvite.findFirst({
where: { email },
select: { id: true },
});

return Boolean(invite);
}

/**
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
* object and keep type safety.
Expand Down Expand Up @@ -64,7 +116,7 @@ function getProviders() {
scope: "read:user user:email",
},
},
})
}),
);
}

Expand All @@ -74,7 +126,7 @@ function getProviders() {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
allowDangerousEmailAccountLinking: true,
})
}),
);
}

Expand All @@ -88,7 +140,7 @@ function getProviders() {
async generateVerificationToken() {
return Math.random().toString(36).substring(2, 7).toLowerCase();
},
})
}),
);
}

Expand All @@ -106,6 +158,7 @@ function getProviders() {
*/
export const authOptions: NextAuthOptions = {
callbacks: {
signIn: async ({ user }) => canRegisterSelfHostedUser(user.email),
session: ({ session, user }) => ({
...session,
user: {
Expand All @@ -117,7 +170,57 @@ export const authOptions: NextAuthOptions = {
},
}),
},
adapter: PrismaAdapter(db) as Adapter,
adapter: (() => {
const prismaAdapter = PrismaAdapter(db);

return {
...prismaAdapter,
async createUser(user: AdapterUser) {
if (env.NEXT_PUBLIC_IS_CLOUD) {
if (!prismaAdapter.createUser) {
throw new Error("Prisma adapter does not support user creation");
}

return prismaAdapter.createUser(user);
}

if (!user.email) {
throw new SelfHostedRegistrationError();
}

return db.$transaction(async (tx) => {
// Acquire the lock before checking for the first user. Without this,
// two concurrent callbacks could both observe an empty User table and
// both create an account without an invitation.
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${SELF_HOSTED_REGISTRATION_LOCK_ID})`;

const firstUser = await tx.user.findFirst({
select: { id: true },
});

if (firstUser) {
const invite = await tx.teamInvite.findFirst({
where: { email: user.email },
select: { id: true },
});

if (!invite) {
throw new SelfHostedRegistrationError();
}
}

return tx.user.create({
data: {
name: user.name,
email: user.email,
emailVerified: user.emailVerified,
image: user.image,
},
});
});
},
Comment on lines +187 to +221

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

next-auth v4 adapter createUser throws error redirect query param error code name

💡 Result:

In NextAuth.js v4, if the createUser method in your adapter throws an error, the sign-in flow is interrupted, and the library attempts to redirect the user to an error page [1]. The error handling mechanism works as follows: 1. Error Redirection: When an error occurs during the sign-in/callback process, NextAuth v4 catches the exception [2]. If the error name matches specific known types (e.g., "CreateUserError"), it initiates a redirect [2]. 2. Query Parameter Construction: The redirect URL is appended with an error query parameter [2][3]. For instance, if an error occurs during user creation, it often results in a redirect to {baseUrl}/error?error=OAuthCreateAccount [2]. 3. Redirect Loops: A common issue reported in v4 is "infinite redirect loops," which often occur when the configured error page itself requires authentication, or when the callbackUrl handling is misconfigured in middleware [4][5][6]. If your error page is protected by middleware, the system may perpetually redirect to the sign-in/error page [6]. 4. Special Characters: If the error message thrown contains unencoded special characters, the redirect may fail due to invalid header content or malformed URLs [7]. It is best practice to log the technical error server-side and pass a generic, URL-safe string as the error code [3][7]. To mitigate these issues: - Verify that your custom error page (configured in pages.error) does not require authentication [4][6]. - Ensure your createUser implementation does not throw raw, unencoded strings that might break URL parsing [7]. - Check for "redirect loops" where the application continuously appends callbackUrl parameters to the URL [8][5]. This is frequently solved by properly configuring the pages options so NextAuth recognizes the sign-in and error pages as public routes [8][5][9]. Top results: [2], [1], [4], [9], [6], [7]

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== apps/web/src/server/auth.ts =="
cat -n apps/web/src/server/auth.ts | sed -n '150,240p'

echo
echo "== search for getAuthErrorMessage and auth error handling =="
rg -n "getAuthErrorMessage|AccessDenied|AdapterError|CreateUserError|OAuthCreateAccount|Unable to sign in" apps/web/src -S

echo
echo "== relevant login page file =="
fd -i "login-page.tsx" apps/web/src

Repository: usesend/useSend

Length of output: 2014


🌐 Web query:

next-auth v4 callback.ts createUser adapter error query param AdapterError CreateUserError AccessDenied

💡 Result:

In NextAuth.js v4, encountering an AdapterError related to createUser typically indicates that your database adapter failed to persist a new user record during the authentication flow [1][2]. The AccessDenied error is often a consequence, occurring because the authentication process halts when the database operation fails [2][3]. To resolve these errors, follow these diagnostic steps: 1. Enable Debugging: Set debug: true in your NextAuth configuration [4][2]. This will provide detailed logs in your terminal, showing exactly what is being passed to the createUser adapter method and the precise database error (e.g., NOT NULL constraint failed, foreign key violation) [4][1][2]. 2. Verify Database Schema: A common cause for AdapterError: CreateUserError is a mismatch between your database schema and the data NextAuth attempts to write [4][5]. For example, if your database column requires a non-null ID but your adapter logic fails to generate or return one, the database will reject the operation [4]. Ensure that your database constraints (like primary keys or required fields) align with the expected data from your provider [4][5]. 3. Check Callback Logic: The AccessDenied error often triggers if your signIn callback returns false or throws an unhandled exception [2][3]. Review your signIn callback for any logic that might conditionally return false, preventing the user from being created in your database [6][3]. 4. Analyze Adapter Implementation: If you are using a custom adapter, ensure it correctly implements the createUser method and properly handles the user object passed to it [1][7]. If using a standard adapter (e.g., Prisma, Drizzle), ensure you are on a compatible version and that your schema migrations are up to date [4][5]. The error is usually a secondary symptom of a database-level rejection; focusing on the underlying database error message found in the [auth][debug] logs is the most effective way to identify the specific constraint or configuration issue [4][2].

Citations:


🌐 Web query:

site:github.com/nextauthjs/next-auth v4 packages/next-auth/src/core/routes/callback.ts createUser error redirect error=

💡 Result:

In NextAuth.js v4, the error redirect to ?error=OAuthCreateAccount is triggered specifically when the adapter fails to create a user during an OAuth callback [1]. In the source file packages/next-auth/src/core/routes/callback.ts, the library wraps the user creation and account linking process in a try-catch block [1]. When an error occurs during this phase, it checks for specific error names [1]. If the caught error is named CreateUserError, the library performs a redirect to the configured error page with the query parameter error=OAuthCreateAccount [1]. Key points regarding this behavior: 1. Cause: This error typically occurs when the database adapter is unable to persist the new user record in your database [1]. This can be due to database constraints (e.g., unique email violation), connection issues, or other operational failures within the adapter's createUser method [1]. 2. Redirect Behavior: The handler intercepts the failure, logs the error internally, and issues a redirect to the error page [1]. This prevents the user from being left in an ambiguous state and directs them to the configured NextAuth error page [1]. 3. Related Events: Note that the createUser event (defined in events in authOptions) is only triggered upon successful user creation when using OAuth providers [2][3]. It is not triggered for Credentials provider sign-ins, nor is it triggered if the database adapter fails during the process [2][3]. 4. Debugging: If you encounter this error in production, check your server logs (which will contain the original error details logged by OAUTH_CALLBACK_HANDLER_ERROR) [1] and verify your database schema and adapter configuration [4]. Ensure that your database adapter is correctly initialized and that it has the necessary permissions to perform write operations [4]. If you are using a custom adapter or complex database logic, ensure that the error thrown by your adapter is correctly bubbling up or is being handled as expected by the NextAuth callback logic [1].

Citations:


createUser needs to preserve the invitation error path

Throwing SelfHostedRegistrationError here surfaces as OAuthCreateAccount, not AccessDenied, so this race-condition guard will fall back to the generic sign-in error instead of the invitation-required copy. Add OAuthCreateAccount to the auth-error mapping or catch this earlier so the locked rejection still shows the intended message.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/server/auth.ts` around lines 177 - 208, Update the auth error
mapping for the createUser flow so SelfHostedRegistrationError thrown inside the
transaction is recognized under OAuthCreateAccount and surfaces as AccessDenied
with the invitation-required message. Preserve the advisory-lock race guard and
existing error behavior for unrelated authentication failures.

} as Adapter;
})(),
pages: {
signIn: "/login",
},
Expand Down
Loading
Loading