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";
|
||||
|
||||
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;
|
||||
email: 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,
|
||||
token,
|
||||
email: valid.email as string,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
props: {
|
||||
valid: false,
|
||||
token: null,
|
||||
email: null,
|
||||
verification,
|
||||
token,
|
||||
},
|
||||
};
|
||||
}) satisfies GetServerSideProps<InviteTokenProps>;
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue