Skip to content
Open
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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,10 @@ AUTH_EMAIL_RATE_LIMIT=5
# REDIS_KEY_PREFIX=""

NEXT_PUBLIC_IS_CLOUD=true

# Restrict new signups.
# false (default) — anyone can sign up (cloud variant still routes uninvited users to the waitlist)
# waitlist — uninvited users always land on the waitlist for admin approval
# true — hard block: uninvited users are rejected at sign-in (no DB entry, no waitlist).
# Existing users and ADMIN_EMAIL can still sign in.
# USESEND_INVITE_ONLY=false
4 changes: 4 additions & 0 deletions apps/web/src/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ export const env = createEnv({
.string()
.optional()
.transform((str) => (str ? parseInt(str, 10) : undefined)),
USESEND_INVITE_ONLY: z
.enum(["false", "waitlist", "true"])
.default("false"),
},

/**
Expand Down Expand Up @@ -133,6 +136,7 @@ export const env = createEnv({
SMTP_USER: process.env.SMTP_USER,
CONTACT_BOOK_ID: process.env.CONTACT_BOOK_ID,
EMAIL_CLEANUP_DAYS: process.env.EMAIL_CLEANUP_DAYS,
USESEND_INVITE_ONLY: process.env.USESEND_INVITE_ONLY,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,22 @@ export const authOptions: NextAuthOptions = {
isWaitlisted: user.isWaitlisted,
},
}),
signIn: async ({ user }) => {
if (env.USESEND_INVITE_ONLY !== "true") return true;
if (!user.email) return false;

const existing = await db.user.findUnique({
where: { email: user.email },
});
if (existing) return true;

if (env.ADMIN_EMAIL && user.email === env.ADMIN_EMAIL) return true;

const invites = await db.teamInvite.findMany({
where: { email: user.email },
});
return invites.length > 0;
Comment on lines +119 to +133
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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Normalize email before admin/invite gating checks.

Line 121 onward uses raw user.email for equality and DB lookups. Mixed-case emails can fail matches and incorrectly block allowed sign-ins.

Suggested fix
     signIn: async ({ user }) => {
       if (env.USESEND_INVITE_ONLY !== "true") return true;
-      if (!user.email) return false;
+      const normalizedEmail = user.email?.trim().toLowerCase();
+      if (!normalizedEmail) return false;

       const existing = await db.user.findUnique({
-        where: { email: user.email },
+        where: { email: normalizedEmail },
       });
       if (existing) return true;

-      if (env.ADMIN_EMAIL && user.email === env.ADMIN_EMAIL) return true;
+      if (
+        env.ADMIN_EMAIL?.trim().toLowerCase() === normalizedEmail
+      ) return true;

       const invites = await db.teamInvite.findMany({
-        where: { email: user.email },
+        where: { email: normalizedEmail },
       });
       return invites.length > 0;
     },
🤖 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 119 - 133, The signIn handler uses
raw user.email for comparisons and DB lookups, which can fail for mixed-case or
spaced emails; normalize the email once (e.g., const normalizedEmail =
user.email.trim().toLowerCase()) at the start of signIn and then use
normalizedEmail for the env.ADMIN_EMAIL comparison, for db.user.findUnique({
where: { email: ... } }) and db.teamInvite.findMany lookups so checks succeed
regardless of casing/whitespace.

},
},
adapter: PrismaAdapter(db) as Adapter,
pages: {
Expand All @@ -133,6 +149,14 @@ export const authOptions: NextAuthOptions = {
invitesAvailable = invites.length > 0;
}

if (env.USESEND_INVITE_ONLY === "waitlist" && !invitesAvailable) {
await db.user.update({
where: { id: user.id },
data: { isBetaUser: true, isWaitlisted: true },
});
return;
Comment on lines +152 to +157
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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve ADMIN_EMAIL exemption in waitlist mode.

Line 152 waitlists every uninvited new user, including ADMIN_EMAIL. That conflicts with the stated exemption for admin sign-in/bootstrap.

Suggested fix
-      if (env.USESEND_INVITE_ONLY === "waitlist" && !invitesAvailable) {
+      if (
+        env.USESEND_INVITE_ONLY === "waitlist" &&
+        !invitesAvailable &&
+        user.email?.trim().toLowerCase() !== env.ADMIN_EMAIL?.trim().toLowerCase()
+      ) {
         await db.user.update({
           where: { id: user.id },
           data: { isBetaUser: true, isWaitlisted: true },
         });
         return;
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (env.USESEND_INVITE_ONLY === "waitlist" && !invitesAvailable) {
await db.user.update({
where: { id: user.id },
data: { isBetaUser: true, isWaitlisted: true },
});
return;
if (
env.USESEND_INVITE_ONLY === "waitlist" &&
!invitesAvailable &&
user.email?.trim().toLowerCase() !== env.ADMIN_EMAIL?.trim().toLowerCase()
) {
await db.user.update({
where: { id: user.id },
data: { isBetaUser: true, isWaitlisted: true },
});
return;
}
🤖 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 152 - 157, The waitlist branch
currently applies to every new user when env.USESEND_INVITE_ONLY === "waitlist",
which also catches the bootstrap/admin account; update the condition to exempt
the admin by checking env.ADMIN_EMAIL and comparing it to user.email and only
mark isWaitlisted when the user is not the admin. Concretely, in the block that
calls db.user.update(...) (and uses invitesAvailable and user), if user.email
=== env.ADMIN_EMAIL then do not set isWaitlisted (you can still set isBetaUser
as needed), otherwise set isWaitlisted: true as before.

}

if (
!env.NEXT_PUBLIC_IS_CLOUD ||
env.NODE_ENV === "development" ||
Expand Down