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.
);