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"; "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 type { GetServerSideProps } from "next";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@ -38,25 +45,47 @@ import { useTranslation } from "~/providers/I18nProvider";
import { generateSSGHelper } from "~/server/utils/ssgHelper"; import { generateSSGHelper } from "~/server/utils/ssgHelper";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
type Valid = { type InviteTokenProps = {
valid: true;
token: string; token: string;
verification:
| {
status: "valid";
email: string; 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] * Pagina di accettazione invito: /auth/accetta-invito/[token]
*/ */
const AcceptInvitePage: NextPageWithLayout<InviteTokenProps> = ({ 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, token,
email, email,
}: InviteTokenProps) => { }: {
token: string;
email: string;
}) => {
const { const {
isVisible, isVisible,
getStrengthColor, getStrengthColor,
@ -91,7 +120,6 @@ const AcceptInvitePage: NextPageWithLayout<InviteTokenProps> = ({
newPsw: "", newPsw: "",
}, },
}); });
function onSubmit(fields: FormValues) { function onSubmit(fields: FormValues) {
if (!email || !token) return; if (!email || !token) return;
acceptInvite({ acceptInvite({
@ -112,27 +140,6 @@ const AcceptInvitePage: NextPageWithLayout<InviteTokenProps> = ({
toast.error(error.message); 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 ( return (
<div className="mx-auto w-full max-w-lg space-y-10 py-6 md:py-10"> <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"> <div className="flex w-full items-center justify-center">
@ -221,6 +228,42 @@ const AcceptInvitePage: NextPageWithLayout<InviteTokenProps> = ({
</div> </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 NewInviteDialog = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -244,7 +287,7 @@ const NewInviteDialog = () => {
return ( return (
<AlertDialog onOpenChange={setOpen} open={open}> <AlertDialog onOpenChange={setOpen} open={open}>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button> <Button size="lg">
<span>Richiedi un nuovo invito</span> <span>Richiedi un nuovo invito</span>
<MailPlus /> <MailPlus />
</Button> </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 helper = generateSSGHelper();
const valid = await helper.invite.verifyInvite.fetch({ token }); const verification = await helper.invite.verifyInvite.fetch({ token, email });
if (valid.valid) {
return { return {
props: { props: {
valid: true, verification,
token, token,
email: valid.email as string,
},
};
}
return {
props: {
valid: false,
token: null,
email: null,
}, },
}; };
}) satisfies GetServerSideProps<InviteTokenProps>; }) satisfies GetServerSideProps<InviteTokenProps>;

View file

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

View file

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