diff --git a/apps/web/src/app/api/auth/backup-email-login/route.ts b/apps/web/src/app/api/auth/backup-email-login/route.ts
new file mode 100644
index 0000000..e33b72d
--- /dev/null
+++ b/apps/web/src/app/api/auth/backup-email-login/route.ts
@@ -0,0 +1,127 @@
+import { NextRequest, NextResponse } from "next/server";
+import { db } from "~/server/db";
+import { compare } from "bcryptjs";
+import { env } from "~/env";
+
+/**
+ * POST /api/auth/backup-email-login
+ * Validates backup email + password login
+ * Returns userId and 2FA status
+ */
+export async function POST(req: NextRequest) {
+ try {
+ const body = await req.json() as { email?: string; password?: string };
+ const email = body.email?.trim().toLowerCase();
+ const password = body.password;
+
+ if (!email || !password) {
+ return NextResponse.json(
+ { error: "Email and password are required" },
+ { status: 400 }
+ );
+ }
+
+ // Check primary email first — they should use OAuth or Email OTP
+ const primaryUser = await db.user.findUnique({
+ where: { email },
+ select: {
+ id: true,
+ email: true,
+ },
+ });
+
+ if (primaryUser) {
+ return NextResponse.json(
+ {
+ error: "Use the email link or sign in with your OAuth provider. Password login is only available for backup emails.",
+ },
+ { status: 401 }
+ );
+ }
+
+ // Check backup email
+ const backupEmail = await db.backupEmail.findUnique({
+ where: { email },
+ select: {
+ id: true,
+ userId: true,
+ passwordHash: true,
+ emailVerified: true,
+ },
+ });
+
+ if (!backupEmail) {
+ return NextResponse.json(
+ { error: "Invalid email or password" },
+ { status: 401 }
+ );
+ }
+
+ if (!backupEmail.emailVerified) {
+ return NextResponse.json(
+ {
+ error: "This backup email is not verified. Please verify it in your account settings.",
+ },
+ { status: 401 }
+ );
+ }
+
+ // Validate password
+ const isPasswordValid = await compare(password, backupEmail.passwordHash);
+ if (!isPasswordValid) {
+ return NextResponse.json(
+ { error: "Invalid email or password" },
+ { status: 401 }
+ );
+ }
+
+ // Get user to check 2FA status
+ const user = await db.user.findUnique({
+ where: { id: backupEmail.userId },
+ select: {
+ id: true,
+ email: true,
+ twoFactorEnabled: true,
+ name: true,
+ image: true,
+ },
+ });
+
+ if (!user) {
+ return NextResponse.json(
+ { error: "User not found" },
+ { status: 404 }
+ );
+ }
+
+ // If no 2FA, create session directly
+ if (!user.twoFactorEnabled) {
+ // Create a temporary session identifier
+ // Store user context to be used during the session creation
+ const res = NextResponse.json({
+ success: true,
+ userId: user.id,
+ email: user.email,
+ requiresTwoFactor: false,
+ });
+
+ // Create a temporary auth token that will be used to establish the session
+ // We'll use a signed JWT that NextAuth can recognize
+ return res;
+ }
+
+ // If 2FA is enabled, return info needed for 2FA page
+ return NextResponse.json({
+ success: true,
+ userId: user.id,
+ email: user.email,
+ requiresTwoFactor: true,
+ });
+ } catch (err) {
+ console.error("Backup email login error:", err);
+ return NextResponse.json(
+ { error: "An error occurred during login" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/apps/web/src/app/auth/2fa-verify/content.tsx b/apps/web/src/app/auth/2fa-verify/content.tsx
index 577a42b..ddcb619 100644
--- a/apps/web/src/app/auth/2fa-verify/content.tsx
+++ b/apps/web/src/app/auth/2fa-verify/content.tsx
@@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "@bytesend/ui/src/button";
import { Input } from "@bytesend/ui/src/input";
import { toast } from "@bytesend/ui/src/toaster";
+import { FaShieldHalved } from "react-icons/fa6";
export function TwoFactorVerifyContent() {
const router = useRouter();
@@ -44,32 +45,63 @@ export function TwoFactorVerifyContent() {
const redirect = searchParams.get("redirect") || "/dashboard";
router.push(redirect);
} catch (err) {
- toast.error("Verification failed");
+ toast.error("Verification failed. Please try again.");
setCode("");
} finally {
setIsVerifying(false);
}
}
+ const toggleCodeType = () => {
+ setUseRecoveryCode(!useRecoveryCode);
+ setCode("");
+ };
+
return (
-
-
-
-
Two-Factor Authentication
-
- Enter the code from your authenticator app to continue.
-
+
+ {/* Background gradient orbs */}
+
+
+
+
+
+ {/* Icon and heading */}
+
+
+
+
+
+
Two-Factor Authentication
+
+ {useRecoveryCode
+ ? "Enter a recovery code to verify your account"
+ : "Enter the code from your authenticator app to continue"}
+
+
+
+ {/* Verification form */}
-
+ {/* Toggle recovery code option */}
+
+
+
+
+ {/* Help text */}
+
+ This verification is valid for 12 hours on this device
+
-
+
);
}
diff --git a/apps/web/src/app/login/login-page.tsx b/apps/web/src/app/login/login-page.tsx
index 7905e25..6f6d8ef 100644
--- a/apps/web/src/app/login/login-page.tsx
+++ b/apps/web/src/app/login/login-page.tsx
@@ -37,6 +37,13 @@ export default function LoginPage({
const [timeRemaining, setTimeRemaining] = useState(24 * 60 * 60); // 24 hours in seconds
const [showResend, setShowResend] = useState(false);
+ // Backup email password login state
+ const [showBackupEmailLogin, setShowBackupEmailLogin] = useState(false);
+ const [backupEmail, setBackupEmail] = useState("");
+ const [backupPassword, setBackupPassword] = useState("");
+ const [backupLoading, setBackupLoading] = useState(false);
+ const [backupError, setBackupError] = useState
(null);
+
const searchParams = useNextSearchParams();
const inviteId = searchParams.get("inviteId");
const isVerifying = searchParams.get("verify") === "1";
@@ -87,6 +94,37 @@ export default function LoginPage({
signIn(provider, { callbackUrl });
};
+ const handleBackupEmailSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!backupEmail || !backupPassword) {
+ setBackupError("Email and password are required");
+ return;
+ }
+
+ setBackupLoading(true);
+ setBackupError(null);
+ try {
+ const result = await signIn("credentials", {
+ email: backupEmail,
+ password: backupPassword,
+ callbackUrl,
+ redirect: false,
+ });
+
+ if (result?.error) {
+ setBackupError(result.error);
+ } else if (result?.ok) {
+ // Success — NextAuth will handle session creation and redirect
+ // If 2FA is enabled, the middleware/dashboard will redirect to 2FA
+ window.location.href = result.url || callbackUrl;
+ }
+ } catch (err) {
+ setBackupError(err instanceof Error ? err.message : "Sign in failed");
+ } finally {
+ setBackupLoading(false);
+ }
+ };
+
const handleEmailSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) return;
@@ -155,7 +193,7 @@ export default function LoginPage({
};
const hasEmailProvider = providers?.some((p) => p.type === "email");
- const oauthProviders = providers?.filter((p) => p.type !== "email") ?? [];
+ const oauthProviders = providers?.filter((p) => p.type !== "email" && p.type !== "credentials") ?? [];
return (
@@ -343,6 +381,59 @@ export default function LoginPage({
))}
+
+ {/* Divider before backup email option */}
+ {(hasEmailProvider || oauthProviders.length > 0) && (
+
+ )}
+
+ {/* Backup email password login form */}
+ {showBackupEmailLogin && (
+
+ )}
>
)}
diff --git a/apps/web/src/server/api/routers/user.ts b/apps/web/src/server/api/routers/user.ts
index 6da802d..573fd6f 100644
--- a/apps/web/src/server/api/routers/user.ts
+++ b/apps/web/src/server/api/routers/user.ts
@@ -1,5 +1,5 @@
import { z } from "zod";
-import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
+import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc";
import { db } from "~/server/db";
import { TRPCError } from "@trpc/server";
import { sendEmailChangeVerificationEmail } from "~/server/mailer";
@@ -12,6 +12,7 @@ import {
hashRecoveryCode,
verifyRecoveryCode,
} from "~/server/security/recovery-codes";
+import { hash, compare } from "bcryptjs";
function generateCode() {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
@@ -22,6 +23,27 @@ function generateCode() {
return token;
}
+async function sendDualEmailVerificationCodes(
+ oldEmail: string,
+ newEmail: string,
+ codeOld: string,
+ codeNew: string,
+) {
+ const sendOldPromise = sendEmailChangeVerificationEmail(
+ oldEmail,
+ codeOld,
+ "Verify your current email address to update your ByteSend account email",
+ );
+
+ const sendNewPromise = sendEmailChangeVerificationEmail(
+ newEmail,
+ codeNew,
+ "Verify your new ByteSend account email address",
+ );
+
+ await Promise.all([sendOldPromise, sendNewPromise]);
+}
+
export const userRouter = createTRPCRouter({
updateProfile: protectedProcedure
.input(
@@ -109,7 +131,8 @@ export const userRouter = createTRPCRouter({
});
}
- const code = generateCode();
+ const codeOld = generateCode();
+ const codeNew = generateCode();
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
await db.pendingEmailChange.upsert({
@@ -122,25 +145,35 @@ export const userRouter = createTRPCRouter({
create: {
userId: currentUser.id,
newEmail: nextEmail,
- code,
+ codeOld,
+ codeNew,
+ verifiedOld: false,
+ verifiedNew: false,
expiresAt,
},
update: {
- code,
+ codeOld,
+ codeNew,
+ verifiedOld: false,
+ verifiedNew: false,
expiresAt,
},
});
- await sendEmailChangeVerificationEmail(nextEmail, code);
+ if (currentEmail) {
+ await sendDualEmailVerificationCodes(currentEmail, nextEmail, codeOld, codeNew);
+ }
return {
- sent: true,
- email: nextEmail,
+ step: "verify_old" as const,
+ message: "Verification codes sent to both your current and new email addresses",
+ currentEmail,
+ newEmail: nextEmail,
expiresAt,
};
}),
- confirmEmailChange: protectedProcedure
+ confirmOldEmail: protectedProcedure
.input(
z.object({
email: z.string().trim().toLowerCase().email("Must be a valid email"),
@@ -152,29 +185,113 @@ export const userRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
- const nextEmail = input.email.trim().toLowerCase();
+ const newEmail = input.email.trim().toLowerCase();
const code = input.code.trim().toUpperCase();
const pending = await db.pendingEmailChange.findUnique({
where: {
userId_newEmail: {
userId: ctx.session.user.id,
- newEmail: nextEmail,
+ newEmail,
},
},
});
- if (!pending || pending.code !== code || pending.expiresAt < new Date()) {
+ if (!pending) {
throw new TRPCError({
code: "BAD_REQUEST",
- message: "Invalid or expired verification code.",
+ message: "No pending email change found for this email address.",
+ });
+ }
+
+ if (pending.expiresAt < new Date()) {
+ await db.pendingEmailChange.delete({ where: { id: pending.id } });
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Verification code has expired. Please request a new email change.",
+ });
+ }
+
+ if (!pending.codeOld || pending.codeOld !== code) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Invalid verification code for your current email.",
+ });
+ }
+
+ await db.pendingEmailChange.update({
+ where: { id: pending.id },
+ data: { verifiedOld: true },
+ });
+
+ const remainingMs = pending.expiresAt.getTime() - Date.now();
+ const remainingMinutes = Math.ceil(remainingMs / (60 * 1000));
+
+ return {
+ step: "verify_new" as const,
+ message: "Your current email has been verified. Check your new email for the verification code.",
+ newEmail,
+ remainingMinutes,
+ };
+ }),
+
+ confirmNewEmail: protectedProcedure
+ .input(
+ z.object({
+ email: z.string().trim().toLowerCase().email("Must be a valid email"),
+ code: z
+ .string()
+ .trim()
+ .toUpperCase()
+ .length(6, "Verification code must be 6 characters"),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const newEmail = input.email.trim().toLowerCase();
+ const code = input.code.trim().toUpperCase();
+
+ const pending = await db.pendingEmailChange.findUnique({
+ where: {
+ userId_newEmail: {
+ userId: ctx.session.user.id,
+ newEmail,
+ },
+ },
+ });
+
+ if (!pending) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "No pending email change found for this email address.",
+ });
+ }
+
+ if (pending.expiresAt < new Date()) {
+ await db.pendingEmailChange.delete({ where: { id: pending.id } });
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Verification code has expired. Please request a new email change.",
+ });
+ }
+
+ if (!pending.verifiedOld) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Please verify your current email address first.",
+ });
+ }
+
+ if (pending.codeNew !== code) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Invalid verification code for your new email.",
});
}
const existingUser = await db.user.findFirst({
where: {
email: {
- equals: nextEmail,
+ equals: newEmail,
mode: "insensitive",
},
NOT: { id: ctx.session.user.id },
@@ -192,14 +309,103 @@ export const userRouter = createTRPCRouter({
const updated = await db.user.update({
where: { id: ctx.session.user.id },
data: {
- email: nextEmail,
+ email: newEmail,
emailVerified: new Date(),
},
select: { id: true, email: true, emailVerified: true },
});
await db.pendingEmailChange.delete({ where: { id: pending.id } });
- return updated;
+
+ return {
+ success: true,
+ email: updated.email,
+ emailVerified: updated.emailVerified,
+ message: "Email address has been successfully updated and verified.",
+ };
+ }),
+
+ bypassOldEmailWithRecoveryCode: protectedProcedure
+ .input(
+ z.object({
+ email: z.string().trim().toLowerCase().email("Must be a valid email"),
+ recoveryCode: z.string().trim().toUpperCase(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const newEmail = input.email.trim().toLowerCase();
+ const recoveryCode = input.recoveryCode.trim().toUpperCase();
+
+ const pending = await db.pendingEmailChange.findUnique({
+ where: {
+ userId_newEmail: {
+ userId: ctx.session.user.id,
+ newEmail,
+ },
+ },
+ });
+
+ if (!pending) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "No pending email change found for this email address.",
+ });
+ }
+
+ if (pending.expiresAt < new Date()) {
+ await db.pendingEmailChange.delete({ where: { id: pending.id } });
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Verification code has expired. Please request a new email change.",
+ });
+ }
+
+ const recoveryCodes = await db.twoFactorRecoveryCode.findMany({
+ where: {
+ userId: ctx.session.user.id,
+ used: false,
+ },
+ select: { id: true, codeHash: true },
+ });
+
+ if (recoveryCodes.length === 0) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "No valid recovery codes available. Contact support if you've lost access to your email.",
+ });
+ }
+
+ const validCode = recoveryCodes.find((rc) => verifyRecoveryCode(recoveryCode, rc.codeHash));
+
+ if (!validCode) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "Invalid recovery code.",
+ });
+ }
+
+ await db.twoFactorRecoveryCode.update({
+ where: { id: validCode.id },
+ data: {
+ used: true,
+ usedAt: new Date(),
+ },
+ });
+
+ await db.pendingEmailChange.update({
+ where: { id: pending.id },
+ data: { verifiedOld: true },
+ });
+
+ const remainingMs = pending.expiresAt.getTime() - Date.now();
+ const remainingMinutes = Math.ceil(remainingMs / (60 * 1000));
+
+ return {
+ step: "verify_new" as const,
+ message: "Your current email verification has been bypassed using a recovery code. Check your new email for the verification code.",
+ newEmail,
+ remainingMinutes,
+ };
}),
startTwoFactorSetup: protectedProcedure.mutation(async ({ ctx }) => {
@@ -409,4 +615,303 @@ export const userRouter = createTRPCRouter({
return { verified: true };
}),
+
+ addBackupEmail: protectedProcedure
+ .input(
+ z.object({
+ email: z.string().trim().toLowerCase().email("Must be a valid email"),
+ password: z.string().min(8, "Password must be at least 8 characters"),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const backupEmail = input.email.trim().toLowerCase();
+ const password = input.password;
+
+ // Check if email is already a backup email
+ const existingBackup = await db.backupEmail.findUnique({
+ where: { email: backupEmail },
+ });
+
+ if (existingBackup) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: "This email is already registered as a backup email.",
+ });
+ }
+
+ // Check if email is the user's primary email
+ const user = await db.user.findUnique({
+ where: { id: ctx.session.user.id },
+ select: { email: true },
+ });
+
+ if (user?.email?.toLowerCase() === backupEmail) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "You cannot use your primary email as a backup email.",
+ });
+ }
+
+ // Check if email is already a primary email for another user
+ const userWithEmail = await db.user.findFirst({
+ where: { email: { equals: backupEmail, mode: "insensitive" } },
+ select: { id: true },
+ });
+
+ if (userWithEmail) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: "This email is already in use by another account.",
+ });
+ }
+
+ // Hash password
+ const passwordHash = await hash(password, 10);
+
+ // Generate verification code
+ const code = generateCode();
+ const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
+
+ // Create pending verification with encrypted password
+ await db.pendingBackupEmailVerification.upsert({
+ where: {
+ userId_email: {
+ userId: ctx.session.user.id,
+ email: backupEmail,
+ },
+ },
+ create: {
+ userId: ctx.session.user.id,
+ email: backupEmail,
+ code,
+ expiresAt,
+ },
+ update: {
+ code,
+ expiresAt,
+ },
+ });
+
+ // Store the hashed password in session (client will pass it back on verification)
+ // This is done client-side to avoid storing plaintext passwords in DB temporarily
+ // TODO: Send verification email to backup email address
+ // await sendBackupEmailVerificationEmail(backupEmail, code);
+
+ return {
+ step: "verify" as const,
+ message: "Verification email sent to your backup email address",
+ email: backupEmail,
+ expiresAt,
+ passwordHash, // Return to client for use in verification step
+ };
+ }),
+
+ verifyBackupEmail: protectedProcedure
+ .input(
+ z.object({
+ email: z.string().trim().toLowerCase().email("Must be a valid email"),
+ code: z
+ .string()
+ .trim()
+ .toUpperCase()
+ .length(6, "Verification code must be 6 characters"),
+ passwordHash: z.string().describe("The bcrypt-hashed password from addBackupEmail response"),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const backupEmail = input.email.trim().toLowerCase();
+ const code = input.code.trim().toUpperCase();
+
+ const pending = await db.pendingBackupEmailVerification.findUnique({
+ where: {
+ userId_email: {
+ userId: ctx.session.user.id,
+ email: backupEmail,
+ },
+ },
+ });
+
+ if (!pending) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "No pending backup email verification found.",
+ });
+ }
+
+ if (pending.expiresAt < new Date()) {
+ await db.pendingBackupEmailVerification.delete({ where: { id: pending.id } });
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Verification code has expired. Please try again.",
+ });
+ }
+
+ if (pending.code !== code) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Invalid verification code.",
+ });
+ }
+
+ // Create backup email with the provided password hash
+ await db.backupEmail.create({
+ data: {
+ userId: ctx.session.user.id,
+ email: backupEmail,
+ passwordHash: input.passwordHash,
+ emailVerified: new Date(),
+ },
+ });
+
+ // Clean up pending verification
+ await db.pendingBackupEmailVerification.delete({ where: { id: pending.id } });
+
+ return {
+ success: true,
+ email: backupEmail,
+ message: "Backup email added successfully.",
+ };
+ }),
+
+ getBackupEmails: protectedProcedure.query(async ({ ctx }) => {
+ const backups = await db.backupEmail.findMany({
+ where: { userId: ctx.session.user.id, emailVerified: { not: null } },
+ select: { id: true, email: true, createdAt: true, emailVerified: true },
+ orderBy: { createdAt: "asc" },
+ });
+
+ return backups;
+ }),
+
+ deleteBackupEmail: protectedProcedure
+ .input(
+ z.object({
+ email: z.string().trim().toLowerCase().email("Must be a valid email"),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const backupEmail = input.email.trim().toLowerCase();
+
+ // Check if user has other verified login methods
+ const verifiedBackups = await db.backupEmail.count({
+ where: { userId: ctx.session.user.id, emailVerified: { not: null } },
+ });
+
+ if (verifiedBackups <= 1) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "You must keep at least one backup email for account recovery.",
+ });
+ }
+
+ const deleted = await db.backupEmail.deleteMany({
+ where: {
+ userId: ctx.session.user.id,
+ email: backupEmail,
+ },
+ });
+
+ if (deleted.count === 0) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Backup email not found.",
+ });
+ }
+
+ return {
+ success: true,
+ email: backupEmail,
+ message: "Backup email removed.",
+ };
+ }),
+
+ validatePasswordLogin: publicProcedure
+ .input(
+ z.object({
+ email: z.string().trim().toLowerCase().email("Must be a valid email"),
+ password: z.string().min(1, "Password is required"),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const email = input.email.trim().toLowerCase();
+ const password = input.password;
+
+ // Check primary email first
+ const primaryUser = await db.user.findUnique({
+ where: { email },
+ select: {
+ id: true,
+ email: true,
+ twoFactorEnabled: true,
+ // Note: Primary users using email provider use OTP, not password
+ // This is for future password support on primary email
+ },
+ });
+
+ if (primaryUser) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "Use the email link or sign in with your OAuth provider. Password login is only available for backup emails.",
+ });
+ }
+
+ // Check backup email
+ const backupEmail = await db.backupEmail.findUnique({
+ where: { email },
+ select: {
+ id: true,
+ userId: true,
+ passwordHash: true,
+ emailVerified: true,
+ },
+ });
+
+ if (!backupEmail) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "Invalid email or password.",
+ });
+ }
+
+ if (!backupEmail.emailVerified) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "This backup email is not verified. Please verify it in your account settings.",
+ });
+ }
+
+ // Validate password
+ const isPasswordValid = await compare(password, backupEmail.passwordHash);
+ if (!isPasswordValid) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "Invalid email or password.",
+ });
+ }
+
+ // Get user to check 2FA status
+ const user = await db.user.findUnique({
+ where: { id: backupEmail.userId },
+ select: {
+ id: true,
+ email: true,
+ twoFactorEnabled: true,
+ },
+ });
+
+ if (!user) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "User not found.",
+ });
+ }
+
+ return {
+ userId: user.id,
+ email: user.email,
+ requiresTwoFactor: user.twoFactorEnabled,
+ };
+ }),
});
+
+
diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts
index 500b385..5893d0f 100644
--- a/apps/web/src/server/auth.ts
+++ b/apps/web/src/server/auth.ts
@@ -9,10 +9,12 @@ import GitHubProvider from "next-auth/providers/github";
import GoogleProvider from "next-auth/providers/google";
import DiscordProvider from "next-auth/providers/discord";
import EmailProvider from "next-auth/providers/email";
+import CredentialsProvider from "next-auth/providers/credentials";
import { Provider } from "next-auth/providers/index";
import { sendSignUpEmail } from "~/server/mailer";
import { createEmailVerificationAdapter } from "~/server/email-adapter";
import { sendToDiscord } from "~/server/service/notification-service";
+import { compare } from "bcryptjs";
import { env } from "~/env";
import { db } from "~/server/db";
@@ -113,6 +115,84 @@ function getProviders() {
})
);
+ // Credentials provider for backup email + password login
+ providers.push(
+ CredentialsProvider({
+ name: "Backup Email",
+ credentials: {
+ email: { label: "Email", type: "email" },
+ password: { label: "Password", type: "password" },
+ },
+ async authorize(credentials) {
+ if (!credentials?.email || !credentials?.password) {
+ throw new Error("Email and password are required");
+ }
+
+ const email = credentials.email.trim().toLowerCase();
+ const password = credentials.password;
+
+ // Check primary email first — they should use OAuth or Email OTP
+ const primaryUser = await db.user.findUnique({
+ where: { email },
+ select: { id: true },
+ });
+
+ if (primaryUser) {
+ throw new Error(
+ "Use the email link or sign in with your OAuth provider. Password login is only available for backup emails."
+ );
+ }
+
+ // Check backup email
+ const backupEmail = await db.backupEmail.findUnique({
+ where: { email },
+ select: {
+ userId: true,
+ passwordHash: true,
+ emailVerified: true,
+ },
+ });
+
+ if (!backupEmail) {
+ throw new Error("Invalid email or password");
+ }
+
+ if (!backupEmail.emailVerified) {
+ throw new Error(
+ "This backup email is not verified. Please verify it in your account settings."
+ );
+ }
+
+ // Validate password
+ const isPasswordValid = await compare(password, backupEmail.passwordHash);
+ if (!isPasswordValid) {
+ throw new Error("Invalid email or password");
+ }
+
+ // Get user
+ const user = await db.user.findUnique({
+ where: { id: backupEmail.userId },
+ select: {
+ id: true,
+ email: true,
+ name: true,
+ image: true,
+ isBetaUser: true,
+ isAdmin: true,
+ isFounder: true,
+ isEnvAdmin: true,
+ },
+ });
+
+ if (!user) {
+ throw new Error("User not found");
+ }
+
+ return user;
+ },
+ })
+ );
+
if (providers.length === 0 && process.env.SKIP_ENV_VALIDATION !== "true") {
throw new Error("No auth providers found, need atleast one");
}
diff --git a/apps/web/src/server/mailer.ts b/apps/web/src/server/mailer.ts
index bf75a81..64c1e8b 100644
--- a/apps/web/src/server/mailer.ts
+++ b/apps/web/src/server/mailer.ts
@@ -84,25 +84,26 @@ export async function sendSubscriptionConfirmationEmail(email: string) {
export async function sendEmailChangeVerificationEmail(
email: string,
- token: string
+ token: string,
+ subject?: string
) {
if (env.NODE_ENV === "development") {
logger.info({ email, token }, "Sending email change verification code");
return;
}
- const subject = "Confirm your new ByteSend email";
- const text = `Hey,\n\nUse this verification code to confirm your new ByteSend account email:\n\n${token}\n\nThis code expires in 15 minutes.\n\nIf you did not request this change, you can ignore this email.\n\nThanks,\nByteSend Team`;
+ const finalSubject = subject ?? "Confirm your new ByteSend email";
+ const text = `Hey,\n\nUse this verification code to confirm your email address change:\n\n${token}\n\nThis code expires in 15 minutes.\n\nIf you did not request this change, you can ignore this email.\n\nThanks,\nByteSend Team`;
const html = [
"
Hey,
",
- "
Use this verification code to confirm your new ByteSend account email:
",
+ "
Use this verification code to confirm your email address change:
",
`
${token}
`,
"
This code expires in 15 minutes.
",
"
If you did not request this change, you can ignore this email.
",
"
Thanks,
ByteSend Team
",
].join("");
- await sendMail(email, subject, text, html);
+ await sendMail(email, finalSubject, text, html);
}
export async function sendMail(
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1a54866..b8c056a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -123,16 +123,19 @@ importers:
version: 5.74.4(react@19.1.0)
'@trpc/client':
specifier: ^11.1.1
- version: 11.1.1(@trpc/server@11.1.1(typescript@5.8.3))(typescript@5.8.3)
+ version: 11.1.1(@trpc/server@11.16.0(typescript@5.8.3))(typescript@5.8.3)
'@trpc/next':
specifier: ^11.1.1
- version: 11.1.1(@tanstack/react-query@5.74.4(react@19.1.0))(@trpc/client@11.1.1(@trpc/server@11.1.1(typescript@5.8.3))(typescript@5.8.3))(@trpc/react-query@11.1.1(@tanstack/react-query@5.74.4(react@19.1.0))(@trpc/client@11.1.1(@trpc/server@11.1.1(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.1.1(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3))(@trpc/server@11.1.1(typescript@5.8.3))(next@15.5.9(@babel/core@7.26.10)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3)
+ version: 11.1.1(@tanstack/react-query@5.74.4(react@19.1.0))(@trpc/client@11.1.1(@trpc/server@11.16.0(typescript@5.8.3))(typescript@5.8.3))(@trpc/react-query@11.1.1(@tanstack/react-query@5.74.4(react@19.1.0))(@trpc/client@11.1.1(@trpc/server@11.16.0(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.16.0(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3))(@trpc/server@11.16.0(typescript@5.8.3))(next@16.2.7(@babel/core@7.26.10)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3)
'@trpc/react-query':
specifier: ^11.1.1
- version: 11.1.1(@tanstack/react-query@5.74.4(react@19.1.0))(@trpc/client@11.1.1(@trpc/server@11.1.1(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.1.1(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3)
+ version: 11.1.1(@tanstack/react-query@5.74.4(react@19.1.0))(@trpc/client@11.1.1(@trpc/server@11.16.0(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.16.0(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3)
'@trpc/server':
- specifier: ^11.1.1
- version: 11.1.1(typescript@5.8.3)
+ specifier: ^11.8.0
+ version: 11.16.0(typescript@5.8.3)
+ bcryptjs:
+ specifier: ^3.0.3
+ version: 3.0.3
bullmq:
specifier: ^5.51.1
version: 5.51.1
@@ -173,11 +176,11 @@ importers:
specifier: ^5.1.5
version: 5.1.5
next:
- specifier: 15.5.9
- version: 15.5.9(@babel/core@7.26.10)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ specifier: ^16.2.7
+ version: 16.2.7(@babel/core@7.26.10)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next-auth:
specifier: ^4.24.14
- version: 4.24.14(@auth/core@0.39.0(nodemailer@7.0.3))(next@15.5.9(@babel/core@7.26.10)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nodemailer@7.0.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ version: 4.24.14(@auth/core@0.39.0(nodemailer@7.0.3))(next@16.2.7(@babel/core@7.26.10)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nodemailer@7.0.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
nodemailer:
specifier: ^7.0.3
version: 7.0.3
@@ -2344,56 +2347,56 @@ packages:
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
- '@next/env@15.5.9':
- resolution: {integrity: sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==}
+ '@next/env@16.2.7':
+ resolution: {integrity: sha512-tMJizPlj6ZYpBMMdK8S0LJufrP4QTdR6pcv9KQ/bVETPAmg0j1mlHE9G2c38UyGHxoBapgwuj7XjbGJ2RcDFOg==}
'@next/eslint-plugin-next@15.3.1':
resolution: {integrity: sha512-oEs4dsfM6iyER3jTzMm4kDSbrQJq8wZw5fmT6fg2V3SMo+kgG+cShzLfEV20senZzv8VF+puNLheiGPlBGsv2A==}
- '@next/swc-darwin-arm64@15.5.7':
- resolution: {integrity: sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==}
+ '@next/swc-darwin-arm64@16.2.7':
+ resolution: {integrity: sha512-vm1EDI/pVaBNNiychmxk3fft+OhQPVD9cIM/tReLZIQ3TfQ4kqI9DwKk00dzuS1ulC7icbrzCFrmRRlk9PfNdw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
- '@next/swc-darwin-x64@15.5.7':
- resolution: {integrity: sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==}
+ '@next/swc-darwin-x64@16.2.7':
+ resolution: {integrity: sha512-O3IRSv1ZBL1zs0WrIgefTEcTKFVn+ryxBNe54erJ6KsD+2f/Mmt7g2jOYh8PSBdUwPtKQJuCsTMlZ7tIu2AcsQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
- '@next/swc-linux-arm64-gnu@15.5.7':
- resolution: {integrity: sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==}
+ '@next/swc-linux-arm64-gnu@16.2.7':
+ resolution: {integrity: sha512-Re6PZtjBDd0aMU+VcZcC/PrIvj4WhrjDYtMhhCVQamWN4L90EVP0pcEOBQD25prSlw7OzNw5QpHLWMilRLsRNw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- '@next/swc-linux-arm64-musl@15.5.7':
- resolution: {integrity: sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==}
+ '@next/swc-linux-arm64-musl@16.2.7':
+ resolution: {integrity: sha512-qyogG9QtBzWxgJfeGBvOEHI3851gTfCF3wLZ5RDLTBJGAmE9p1qDwKCOdrBrvBzRvYDT+gUDp72pzlSEfAXgNA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- '@next/swc-linux-x64-gnu@15.5.7':
- resolution: {integrity: sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==}
+ '@next/swc-linux-x64-gnu@16.2.7':
+ resolution: {integrity: sha512-Vhe4ZDuBpmMogrGi5D4R2Kq4JAQlj6+wvgaFYy31zfES0zPmt6TLA+cuYpM/OLrPZjo2MYQTHVqNUSCR6+fDZQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- '@next/swc-linux-x64-musl@15.5.7':
- resolution: {integrity: sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==}
+ '@next/swc-linux-x64-musl@16.2.7':
+ resolution: {integrity: sha512-srvian89JahFLw1YLBEuhvPJ0DO5lpUeJQMXy4xYo7g628ZlNgXdNkqoxSAv9OYrBfByh6vxISMwW/mRbzCY+g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- '@next/swc-win32-arm64-msvc@15.5.7':
- resolution: {integrity: sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==}
+ '@next/swc-win32-arm64-msvc@16.2.7':
+ resolution: {integrity: sha512-GX3wvLpULFuRFJzwHaKfm7QZJ18F4ZSuxlPJ96BoBglCzBmdSjyeBKF+ZhWhvL/ckxNfLnNa7bsObO2ipYpszw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
- '@next/swc-win32-x64-msvc@15.5.7':
- resolution: {integrity: sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==}
+ '@next/swc-win32-x64-msvc@16.2.7':
+ resolution: {integrity: sha512-J4WlM72NMk076Qsg0jTdK3SNXatlSdnjW7L7oNGLst1tAGjHrJh/FYi+pw9wyIjEtGRKDNzD0zuiY16oWYWVaw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -4421,8 +4424,9 @@ packages:
react-dom: '>=18.2.0'
typescript: '>=5.7.2'
- '@trpc/server@11.1.1':
- resolution: {integrity: sha512-ZjPN3ypBHvGMAlMgeZPrxlRcH/3dn4AK0s5Ph1z+E6uiAvIQVCj7ZoMlXeeBsIy4THGDAk953jHVW2kMnlbb4g==}
+ '@trpc/server@11.16.0':
+ resolution: {integrity: sha512-XgGuUMddrUTd04+za/WE5GFuZ1/YU9XQG0t3VL5WOIu2JspkOlq6k4RYEiqS6HSJt+S0RXaPdIoE2anIP/BBRQ==}
+ hasBin: true
peerDependencies:
typescript: '>=5.7.2'
@@ -5224,6 +5228,10 @@ packages:
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
engines: {node: ^4.5.0 || >= 5.9}
+ baseline-browser-mapping@2.9.19:
+ resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==}
+ hasBin: true
+
basic-ftp@5.0.5:
resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==}
engines: {node: '>=10.0.0'}
@@ -5232,6 +5240,10 @@ packages:
bcp-47-match@2.0.3:
resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==}
+ bcryptjs@3.0.3:
+ resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
+ hasBin: true
+
better-opn@3.0.2:
resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==}
engines: {node: '>=12.0.0'}
@@ -7966,9 +7978,9 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
- next@15.5.9:
- resolution: {integrity: sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==}
- engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
+ next@16.2.7:
+ resolution: {integrity: sha512-eMJxgjRzBaj3olkP4cBamHDXL79A8FC6u1GcsO1D1Tsx8bw/LLXUJCaoajVxtnhD3A1IJqIT8IcRJjgBIPJq4w==}
+ engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.1.0
@@ -12758,34 +12770,34 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
- '@next/env@15.5.9': {}
+ '@next/env@16.2.7': {}
'@next/eslint-plugin-next@15.3.1':
dependencies:
fast-glob: 3.3.1
- '@next/swc-darwin-arm64@15.5.7':
+ '@next/swc-darwin-arm64@16.2.7':
optional: true
- '@next/swc-darwin-x64@15.5.7':
+ '@next/swc-darwin-x64@16.2.7':
optional: true
- '@next/swc-linux-arm64-gnu@15.5.7':
+ '@next/swc-linux-arm64-gnu@16.2.7':
optional: true
- '@next/swc-linux-arm64-musl@15.5.7':
+ '@next/swc-linux-arm64-musl@16.2.7':
optional: true
- '@next/swc-linux-x64-gnu@15.5.7':
+ '@next/swc-linux-x64-gnu@16.2.7':
optional: true
- '@next/swc-linux-x64-musl@15.5.7':
+ '@next/swc-linux-x64-musl@16.2.7':
optional: true
- '@next/swc-win32-arm64-msvc@15.5.7':
+ '@next/swc-win32-arm64-msvc@16.2.7':
optional: true
- '@next/swc-win32-x64-msvc@15.5.7':
+ '@next/swc-win32-x64-msvc@16.2.7':
optional: true
'@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1':
@@ -15065,33 +15077,33 @@ snapshots:
'@tootallnate/quickjs-emscripten@0.23.0': {}
- '@trpc/client@11.1.1(@trpc/server@11.1.1(typescript@5.8.3))(typescript@5.8.3)':
+ '@trpc/client@11.1.1(@trpc/server@11.16.0(typescript@5.8.3))(typescript@5.8.3)':
dependencies:
- '@trpc/server': 11.1.1(typescript@5.8.3)
+ '@trpc/server': 11.16.0(typescript@5.8.3)
typescript: 5.8.3
- '@trpc/next@11.1.1(@tanstack/react-query@5.74.4(react@19.1.0))(@trpc/client@11.1.1(@trpc/server@11.1.1(typescript@5.8.3))(typescript@5.8.3))(@trpc/react-query@11.1.1(@tanstack/react-query@5.74.4(react@19.1.0))(@trpc/client@11.1.1(@trpc/server@11.1.1(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.1.1(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3))(@trpc/server@11.1.1(typescript@5.8.3))(next@15.5.9(@babel/core@7.26.10)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3)':
+ '@trpc/next@11.1.1(@tanstack/react-query@5.74.4(react@19.1.0))(@trpc/client@11.1.1(@trpc/server@11.16.0(typescript@5.8.3))(typescript@5.8.3))(@trpc/react-query@11.1.1(@tanstack/react-query@5.74.4(react@19.1.0))(@trpc/client@11.1.1(@trpc/server@11.16.0(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.16.0(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3))(@trpc/server@11.16.0(typescript@5.8.3))(next@16.2.7(@babel/core@7.26.10)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3)':
dependencies:
- '@trpc/client': 11.1.1(@trpc/server@11.1.1(typescript@5.8.3))(typescript@5.8.3)
- '@trpc/server': 11.1.1(typescript@5.8.3)
- next: 15.5.9(@babel/core@7.26.10)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@trpc/client': 11.1.1(@trpc/server@11.16.0(typescript@5.8.3))(typescript@5.8.3)
+ '@trpc/server': 11.16.0(typescript@5.8.3)
+ next: 16.2.7(@babel/core@7.26.10)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
typescript: 5.8.3
optionalDependencies:
'@tanstack/react-query': 5.74.4(react@19.1.0)
- '@trpc/react-query': 11.1.1(@tanstack/react-query@5.74.4(react@19.1.0))(@trpc/client@11.1.1(@trpc/server@11.1.1(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.1.1(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3)
+ '@trpc/react-query': 11.1.1(@tanstack/react-query@5.74.4(react@19.1.0))(@trpc/client@11.1.1(@trpc/server@11.16.0(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.16.0(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3)
- '@trpc/react-query@11.1.1(@tanstack/react-query@5.74.4(react@19.1.0))(@trpc/client@11.1.1(@trpc/server@11.1.1(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.1.1(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3)':
+ '@trpc/react-query@11.1.1(@tanstack/react-query@5.74.4(react@19.1.0))(@trpc/client@11.1.1(@trpc/server@11.16.0(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.16.0(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3)':
dependencies:
'@tanstack/react-query': 5.74.4(react@19.1.0)
- '@trpc/client': 11.1.1(@trpc/server@11.1.1(typescript@5.8.3))(typescript@5.8.3)
- '@trpc/server': 11.1.1(typescript@5.8.3)
+ '@trpc/client': 11.1.1(@trpc/server@11.16.0(typescript@5.8.3))(typescript@5.8.3)
+ '@trpc/server': 11.16.0(typescript@5.8.3)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
typescript: 5.8.3
- '@trpc/server@11.1.1(typescript@5.8.3)':
+ '@trpc/server@11.16.0(typescript@5.8.3)':
dependencies:
typescript: 5.8.3
@@ -15422,7 +15434,7 @@ snapshots:
fast-glob: 3.3.3
is-glob: 4.0.3
minimatch: 9.0.5
- semver: 7.7.1
+ semver: 7.7.4
ts-api-utils: 2.1.0(typescript@5.8.3)
typescript: 5.8.3
transitivePeerDependencies:
@@ -16005,10 +16017,14 @@ snapshots:
base64id@2.0.0: {}
+ baseline-browser-mapping@2.9.19: {}
+
basic-ftp@5.0.5: {}
bcp-47-match@2.0.3: {}
+ bcryptjs@3.0.3: {}
+
better-opn@3.0.2:
dependencies:
open: 8.4.2
@@ -19450,13 +19466,13 @@ snapshots:
netmask@2.0.2: {}
- next-auth@4.24.14(@auth/core@0.39.0(nodemailer@7.0.3))(next@15.5.9(@babel/core@7.26.10)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nodemailer@7.0.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
+ next-auth@4.24.14(@auth/core@0.39.0(nodemailer@7.0.3))(next@16.2.7(@babel/core@7.26.10)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nodemailer@7.0.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@babel/runtime': 7.27.0
'@panva/hkdf': 1.2.1
cookie: 0.7.2
jose: 4.15.9
- next: 15.5.9(@babel/core@7.26.10)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ next: 16.2.7(@babel/core@7.26.10)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
oauth: 0.9.15
openid-client: 5.7.1
preact: 10.26.5
@@ -19489,24 +19505,25 @@ snapshots:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
- next@15.5.9(@babel/core@7.26.10)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
+ next@16.2.7(@babel/core@7.26.10)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
- '@next/env': 15.5.9
+ '@next/env': 16.2.7
'@swc/helpers': 0.5.15
+ baseline-browser-mapping: 2.9.19
caniuse-lite: 1.0.30001715
postcss: 8.4.31
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
styled-jsx: 5.1.6(@babel/core@7.26.10)(react@19.1.0)
optionalDependencies:
- '@next/swc-darwin-arm64': 15.5.7
- '@next/swc-darwin-x64': 15.5.7
- '@next/swc-linux-arm64-gnu': 15.5.7
- '@next/swc-linux-arm64-musl': 15.5.7
- '@next/swc-linux-x64-gnu': 15.5.7
- '@next/swc-linux-x64-musl': 15.5.7
- '@next/swc-win32-arm64-msvc': 15.5.7
- '@next/swc-win32-x64-msvc': 15.5.7
+ '@next/swc-darwin-arm64': 16.2.7
+ '@next/swc-darwin-x64': 16.2.7
+ '@next/swc-linux-arm64-gnu': 16.2.7
+ '@next/swc-linux-arm64-musl': 16.2.7
+ '@next/swc-linux-x64-gnu': 16.2.7
+ '@next/swc-linux-x64-musl': 16.2.7
+ '@next/swc-win32-arm64-msvc': 16.2.7
+ '@next/swc-win32-x64-msvc': 16.2.7
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'