From 028df8bf27a8c5d8879ce931974f7787acb3083e Mon Sep 17 00:00:00 2001 From: Joshua Belke Date: Tue, 7 Apr 2026 14:58:27 -0400 Subject: [PATCH] fix: remove encodeURIComponent from invite link email parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The @ symbol in email addresses was being encoded as %40 when constructing invite URLs, causing broken or garbled links when copied/shared by users. - Remove encodeURIComponent(email) from server-side invite link construction in inviteUser.ts (both new invite and regenerate paths) - Remove encodeURIComponent(email) from client-side redirect URLs in InviteStatusCard.tsx (login, signup, and useEffect redirect paths) - Valid Zod-validated email addresses do not contain characters that require URL encoding for safe query parameter use (@ is permitted in query strings per RFC 3986 ยง3.4) --- server/routers/user/inviteUser.ts | 22 ++++++++++++++-------- src/components/InviteStatusCard.tsx | 18 ++++++++++++------ 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts index 7ac1849b9..b11586e69 100644 --- a/server/routers/user/inviteUser.ts +++ b/server/routers/user/inviteUser.ts @@ -1,7 +1,14 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { orgs, roles, userInviteRoles, userInvites, userOrgs, users } from "@server/db"; +import { + orgs, + roles, + userInviteRoles, + userInvites, + userOrgs, + users +} from "@server/db"; import { and, eq, inArray } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -37,8 +44,7 @@ const inviteUserBodySchema = z regenerate: z.boolean().optional() }) .refine( - (d) => - (d.roleIds != null && d.roleIds.length > 0) || d.roleId != null, + (d) => (d.roleIds != null && d.roleIds.length > 0) || d.roleId != null, { message: "roleIds or roleId is required", path: ["roleIds"] } ) .transform((data) => ({ @@ -265,7 +271,7 @@ export async function inviteUser( ) ); - const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`; + const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${email}`; if (doEmail) { await sendEmail( @@ -314,12 +320,12 @@ export async function inviteUser( expiresAt, tokenHash }); - await trx.insert(userInviteRoles).values( - uniqueRoleIds.map((roleId) => ({ inviteId, roleId })) - ); + await trx + .insert(userInviteRoles) + .values(uniqueRoleIds.map((roleId) => ({ inviteId, roleId }))); }); - const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`; + const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${email}`; if (doEmail) { await sendEmail( diff --git a/src/components/InviteStatusCard.tsx b/src/components/InviteStatusCard.tsx index 417fa9892..5de8f25fd 100644 --- a/src/components/InviteStatusCard.tsx +++ b/src/components/InviteStatusCard.tsx @@ -39,7 +39,11 @@ export default function InviteStatusCard({ const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [type, setType] = useState< - "rejected" | "wrong_user" | "user_does_not_exist" | "not_logged_in" | "user_limit_exceeded" + | "rejected" + | "wrong_user" + | "user_does_not_exist" + | "not_logged_in" + | "user_limit_exceeded" >("rejected"); useEffect(() => { @@ -90,12 +94,12 @@ export default function InviteStatusCard({ if (!user && type === "user_does_not_exist") { const redirectUrl = email - ? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}` + ? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}` : `/auth/signup?redirect=/invite?token=${tokenParam}`; router.push(redirectUrl); } else if (!user && type === "not_logged_in") { const redirectUrl = email - ? `/auth/login?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}` + ? `/auth/login?redirect=/invite?token=${tokenParam}&email=${email}` : `/auth/login?redirect=/invite?token=${tokenParam}`; router.push(redirectUrl); } else { @@ -109,7 +113,7 @@ export default function InviteStatusCard({ async function goToLogin() { await api.post("/auth/logout", {}); const redirectUrl = email - ? `/auth/login?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}` + ? `/auth/login?redirect=/invite?token=${tokenParam}&email=${email}` : `/auth/login?redirect=/invite?token=${tokenParam}`; router.push(redirectUrl); } @@ -117,7 +121,7 @@ export default function InviteStatusCard({ async function goToSignup() { await api.post("/auth/logout", {}); const redirectUrl = email - ? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}` + ? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}` : `/auth/signup?redirect=/invite?token=${tokenParam}`; router.push(redirectUrl); } @@ -157,7 +161,9 @@ export default function InviteStatusCard({ Cannot Accept Invite

- This organization has reached its user limit. Please contact the organization administrator to upgrade their plan before accepting this invite. + This organization has reached its user limit. Please + contact the organization administrator to upgrade their + plan before accepting this invite.

);