feat: enhance invite token verification and processing with email handling

This commit is contained in:
Marco Pedone 2026-04-14 17:23:13 +02:00
parent 28c40be091
commit 7fa36c4b72
3 changed files with 136 additions and 66 deletions

View file

@ -1,6 +1,13 @@
"use client";
import { ExternalLink, Eye, EyeOff, Frown, MailPlus } from "lucide-react";
import {
ExternalLink,
Eye,
EyeOff,
Frown,
MailPlus,
UserSearch,
} from "lucide-react";
import type { GetServerSideProps } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
@ -38,25 +45,47 @@ import { useTranslation } from "~/providers/I18nProvider";
import { generateSSGHelper } from "~/server/utils/ssgHelper";
import { api } from "~/utils/api";
type Valid = {
valid: true;
type InviteTokenProps = {
token: string;
verification:
| {
status: "valid";
email: string;
}
| {
status: "invalid";
reason: "not_found" | "expired";
}
| {
status: "already_user";
};
};
type Invalid = {
valid: false;
token: null;
email: null;
};
type InviteTokenProps = Valid | Invalid;
/**
* Pagina di accettazione invito: /auth/accetta-invito/[token]
*/
const AcceptInvitePage: NextPageWithLayout<InviteTokenProps> = ({
valid,
verification,
token,
}: InviteTokenProps) => {
switch (verification.status) {
case "valid":
return <ValidInvitePage email={verification.email} token={token} />;
case "invalid":
return <InvalidInvitePage reason={verification.reason} />;
case "already_user":
return <AlreadyUserPage />;
default:
return <InvalidInvitePage reason="not_found" />;
}
};
const ValidInvitePage = ({
token,
email,
}: InviteTokenProps) => {
}: {
token: string;
email: string;
}) => {
const {
isVisible,
getStrengthColor,
@ -91,7 +120,6 @@ const AcceptInvitePage: NextPageWithLayout<InviteTokenProps> = ({
newPsw: "",
},
});
function onSubmit(fields: FormValues) {
if (!email || !token) return;
acceptInvite({
@ -112,27 +140,6 @@ const AcceptInvitePage: NextPageWithLayout<InviteTokenProps> = ({
toast.error(error.message);
},
});
if (!valid) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center">
<Frown className="mx-auto mb-4 size-28 text-muted-foreground" />
<h1 className="font-bold text-xl">Invito non valido</h1>
<p>L'invito è scaduto o è già stato utilizzato.</p>
<div className="flex flex-col items-center gap-4 pt-4 md:flex-row">
<Link href="/login">
<Button variant="info">
<span>Vai al Login</span>
<ExternalLink />
</Button>
</Link>
<NewInviteDialog />
</div>
</div>
);
}
return (
<div className="mx-auto w-full max-w-lg space-y-10 py-6 md:py-10">
<div className="flex w-full items-center justify-center">
@ -221,6 +228,42 @@ const AcceptInvitePage: NextPageWithLayout<InviteTokenProps> = ({
</div>
);
};
const AlreadyUserPage = () => {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center">
<UserSearch className="mx-auto mb-4 size-28 text-muted-foreground" />
<h1 className="font-bold text-xl">Utente già registrato</h1>
<p>L'email associata a questo invito è già registrata.</p>
<div className="flex flex-col items-center gap-4 pt-4 md:flex-row">
<Link href="/login">
<Button size="lg" variant="info">
<span>Vai al Login</span>
<ExternalLink />
</Button>
</Link>
</div>
</div>
);
};
const InvalidInvitePage = ({ reason }: { reason: "not_found" | "expired" }) => {
const message =
reason === "expired"
? "L'invito è scaduto, è passato troppo tempo dall'invio."
: "Non è stato trovato alcun invito valido per questo collegamento.";
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center">
<Frown className="mx-auto mb-4 size-28 text-muted-foreground" />
<h1 className="font-bold text-xl">Invito non valido</h1>
<p>{message}</p>
<div className="flex flex-col items-center gap-4 pt-4 md:flex-row">
<NewInviteDialog />
</div>
</div>
);
};
const NewInviteDialog = () => {
const { t } = useTranslation();
@ -244,7 +287,7 @@ const NewInviteDialog = () => {
return (
<AlertDialog onOpenChange={setOpen} open={open}>
<AlertDialogTrigger asChild>
<Button>
<Button size="lg">
<span>Richiedi un nuovo invito</span>
<MailPlus />
</Button>
@ -297,24 +340,24 @@ export const getServerSideProps = (async (context) => {
},
};
}
const e = context.query.e;
let email: string | null = null;
if (typeof e === "string") {
try {
email = atob(e);
} catch {
email = null;
}
}
const helper = generateSSGHelper();
const valid = await helper.invite.verifyInvite.fetch({ token });
const verification = await helper.invite.verifyInvite.fetch({ token, email });
if (valid.valid) {
return {
props: {
valid: true,
verification,
token,
email: valid.email as string,
},
};
}
return {
props: {
valid: false,
token: null,
email: null,
},
};
}) satisfies GetServerSideProps<InviteTokenProps>;

View file

@ -33,7 +33,7 @@ export const inviteRouter = createTRPCRouter({
const token = await createInviteToken(user.email);
const inviteUrl = `${env.BASE_URL}/auth/accetta-invito/${token}`;
const inviteUrl = `${env.BASE_URL}/auth/accetta-invito/${token}?e=${btoa(user.email)}`;
await sendInvitoEmail({
userId: input.id,
@ -66,7 +66,7 @@ export const inviteRouter = createTRPCRouter({
}
const token = await createInviteToken(user.email);
const inviteUrl = `${env.BASE_URL}/auth/accetta-invito/${token}`;
const inviteUrl = `${env.BASE_URL}/auth/accetta-invito/${token}?e=${btoa(user.email)}`;
await sendInvitoEmail({
userId: user.id,
@ -89,21 +89,32 @@ export const inviteRouter = createTRPCRouter({
// Verify invite token (public - user clicking the link)
verifyInvite: publicProcedure
.input(z.object({ token: z.string() }))
.input(z.object({ token: z.string(), email: z.string().nullable() }))
.output(
z.discriminatedUnion("valid", [
z.object({ valid: z.literal(true), email: z.email() }),
z.object({ valid: z.literal(false), email: z.null() }),
z.discriminatedUnion("status", [
z.object({ status: z.literal("valid"), email: z.email() }),
z.object({
status: z.literal("invalid"),
reason: z.enum(["not_found", "expired"]),
}),
z.object({ status: z.literal("already_user") }),
]),
)
.query(async ({ input }) => {
const invite = await verifyInviteToken(input.token);
if (!invite) {
return { valid: false, email: null };
if (input.email) {
const existingUser = await findUser_byEmail({ email: input.email });
if (existingUser) {
return { status: "already_user" };
}
}
return { valid: true, email: invite.email };
const ver = await verifyInviteToken(input.token);
if (!ver.status) {
return { status: "invalid", reason: ver.reason };
}
return { status: "valid", email: ver.invite.email };
}),
// Accept invite and set password
@ -123,9 +134,9 @@ export const inviteRouter = createTRPCRouter({
message: `Accept Invite: Utente non trovato con email: ${input.email}`,
});
}
const invite = await verifyInviteToken(input.token);
const ver = await verifyInviteToken(input.token);
if (!invite) {
if (!ver.status) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Accept Invite: Invito non valido o scaduto per email: ${input.email}`,

View file

@ -1,5 +1,6 @@
import crypto from "node:crypto";
import { TRPCError } from "@trpc/server";
import type { UserInvites } from "~/schemas/public/UserInvites";
import type { UsersId } from "~/schemas/public/Users";
import { db } from "~/server/db";
import { NewMail } from "../services/mailer";
@ -31,18 +32,33 @@ export async function createInviteToken(email: string) {
return token;
}
export async function verifyInviteToken(token: string) {
type VerifyInviteResult =
| {
status: true;
invite: UserInvites;
}
| {
status: false;
reason: "not_found" | "expired";
};
export async function verifyInviteToken(
token: string,
): Promise<VerifyInviteResult> {
try {
const invite = await db
.selectFrom("user_invites")
.selectAll()
.selectAll("user_invites")
.where("token", "=", token)
.where("expires_at", ">", new Date())
//.where("expires_at", ">", new Date())
.executeTakeFirst();
if (!invite) {
return null;
return { status: false, reason: "not_found" };
}
return invite;
if (invite.expires_at < new Date()) {
return { status: false, reason: "expired" };
}
return { status: true, invite };
} catch (e) {
console.error("Error verifying invite token:", e);
throw new TRPCError({