feat: enhance invite token verification and processing with email handling
This commit is contained in:
parent
28c40be091
commit
7fa36c4b72
3 changed files with 136 additions and 66 deletions
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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}`,
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue