feat: implement user invitation system with email invites and password setup

- Added user_invites table to manage invitation tokens.
- Created invite router with procedures for sending, verifying, and accepting invites.
- Implemented email sending functionality for invites.
- Updated user controller to handle password setting during invite acceptance.
- Introduced mustChangePassword flag in user schema and related logic.
- Added middleware to enforce password change requirement.
- Created forms for accepting invites and changing passwords.
- Updated database migrations to reflect new user invite structure and fields.
This commit is contained in:
Marco Pedone 2025-12-14 18:57:56 +01:00
parent 5b2a3964ac
commit 4d7802ccc6
42 changed files with 1004 additions and 623 deletions

View file

@ -0,0 +1,9 @@
--- Removes verification columns from users table
ALTER TABLE public.users
DROP COLUMN IF EXISTS "isVerified",
DROP COLUMN IF EXISTS "verificationToken",
DROP COLUMN IF EXISTS "verificationTokenExpires";
--Add mustChangePassword column to users table
ALTER TABLE public.users
ADD COLUMN IF NOT EXISTS "mustChangePassword" BOOLEAN DEFAULT FALSE NOT NULL;

View file

@ -0,0 +1,23 @@
--- user_invites table
CREATE TABLE IF NOT EXISTS public.user_invites (
id UUID DEFAULT gen_random_uuid () NOT NULL,
email TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
expires_at TIMESTAMP WITHOUT TIME ZONE NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now()
);
-- Primary Key
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'user_invites_pkey'
AND conrelid = 'public.user_invites'::regclass
) THEN
ALTER TABLE public.user_invites
ADD CONSTRAINT user_invites_pkey PRIMARY KEY (id);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_user_invites_token ON public.user_invites (token);

View file

@ -0,0 +1,45 @@
import { Button, Heading, Row, Section, Text } from "@react-email/components";
import Base from "./base";
export type Invito_NewMail = {
mailType: "invito";
props: InvitoProps;
};
type InvitoProps = {
nome: string;
inviteUrl: string;
};
const Invito = ({ nome, inviteUrl }: InvitoProps) => {
return (
<Base preview="Procedi ora su Infoalloggi.it">
<>
<Heading className="text-center text-3xl leading-8">
Procedi ora su Infoalloggi.it
</Heading>
<Section className="px-5 text-left text-base md:px-12">
<Row>
<Text>
Ciao {nome},<br />
Accedi ora al servizio di ricerca Infoalloggi, dove potrai andare
a contattare direttamente i proprietari degli immobili
disponibili.
</Text>
</Row>
<Section className="mb-5">
<Button
className="mx-auto box-border inline-flex h-10 items-center justify-center whitespace-nowrap rounded-md bg-sky-500 px-4 py-2 text-center align-middle font-semibold text-white"
href={inviteUrl}
>
Clicca qui per procedere
</Button>
</Section>
<Section></Section>
</Section>
</>
</Base>
);
};
export default Invito;

View file

@ -59,7 +59,7 @@
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet-defaulticon-compatibility": "^0.1.2", "leaflet-defaulticon-compatibility": "^0.1.2",
"lucide-react": "^0.536.0", "lucide-react": "^0.536.0",
"next": "15.4.8", "next": "^15.4.10",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nextjs-progressbar": "^0.0.16", "nextjs-progressbar": "^0.0.16",
"nodemailer": "^7.0.5", "nodemailer": "^7.0.5",
@ -2785,9 +2785,9 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "15.4.8", "version": "15.4.10",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.4.8.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.4.10.tgz",
"integrity": "sha512-LydLa2MDI1NMrOFSkO54mTc8iIHSttj6R6dthITky9ylXV2gCGi0bHQjVCtLGRshdRPjyh2kXbxJukDtBWQZtQ==", "integrity": "sha512-knhmoJ0Vv7VRf6pZEPSnciUG1S4bIhWx+qTYBW/AjxEtlzsiNORPk8sFDCEvqLfmKuey56UB9FL1UdHEV3uBrg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
@ -14138,13 +14138,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/next": { "node_modules/next": {
"version": "15.4.8", "version": "15.4.10",
"resolved": "https://registry.npmjs.org/next/-/next-15.4.8.tgz", "resolved": "https://registry.npmjs.org/next/-/next-15.4.10.tgz",
"integrity": "sha512-jwOXTz/bo0Pvlf20FSb6VXVeWRssA2vbvq9SdrOPEg9x8E1B27C2rQtvriAn600o9hH61kjrVRexEffv3JybuA==", "integrity": "sha512-itVlc79QjpKMFMRhP+kbGKaSG/gZM6RCvwhEbwmCNF06CdDiNaoHcbeg0PqkEa2GOcn8KJ0nnc7+yL7EjoYLHQ==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@next/env": "15.4.8", "@next/env": "15.4.10",
"@swc/helpers": "0.5.15", "@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579", "caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31", "postcss": "8.4.31",

View file

@ -71,7 +71,7 @@
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet-defaulticon-compatibility": "^0.1.2", "leaflet-defaulticon-compatibility": "^0.1.2",
"lucide-react": "^0.536.0", "lucide-react": "^0.536.0",
"next": "15.4.8", "next": "15.4.10",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nextjs-progressbar": "^0.0.16", "nextjs-progressbar": "^0.0.16",
"nodemailer": "^7.0.5", "nodemailer": "^7.0.5",

View file

@ -16,6 +16,7 @@ import { useRouter } from "next/router";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useUserViewContext } from "~/lib/userViewContext"; import { useUserViewContext } from "~/lib/userViewContext";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { Confirm } from "../confirm";
import { WhatsAppIcon2 } from "../svgs"; import { WhatsAppIcon2 } from "../svgs";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { Separator } from "../ui/separator"; import { Separator } from "../ui/separator";
@ -31,6 +32,14 @@ export const UserViewHeader = () => {
router.reload(); router.reload();
}, },
}); });
const { mutateAsync: sendInvite } = api.invite.sendInvite.useMutation({
onError: (error) => {
toast.error(error.message);
},
onSuccess: async () => {
toast.success("Email inviata con successo");
},
});
if (!data) return <div>Errore</div>; if (!data) return <div>Errore</div>;
return ( return (
<div className="mb-5 flex flex-col space-y-5"> <div className="mb-5 flex flex-col space-y-5">
@ -50,6 +59,17 @@ export const UserViewHeader = () => {
<UserPen /> Intestazione <UserPen /> Intestazione
</Button> </Button>
)} )}
<Confirm
description={`Verrà inviata un'email di invito al sito all'indirizzo ${data.email}`}
onConfirm={async () => {
await sendInvite({ id: data.id });
}}
title="Vuoi mandare l'email di invito al sito?"
>
<Button size="sm" variant="info">
<Mail /> Invita al sito
</Button>
</Confirm>
</div> </div>
<div className="flex flex-row flex-wrap gap-2"> <div className="flex flex-row flex-wrap gap-2">
<Link href={`/area-riservata/admin/user-view/edit-user/${data.id}`}> <Link href={`/area-riservata/admin/user-view/edit-user/${data.id}`}>

View file

@ -2,13 +2,11 @@ import {
Copy, Copy,
EllipsisVertical, EllipsisVertical,
ExternalLink, ExternalLink,
Mail,
Trash2, Trash2,
Wrench, Wrench,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { Confirm } from "~/components/confirm";
import { LoadingPage } from "~/components/loading"; import { LoadingPage } from "~/components/loading";
import { AddAnnuncio } from "~/components/servizio/servizio_actions"; import { AddAnnuncio } from "~/components/servizio/servizio_actions";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
@ -65,15 +63,6 @@ export const TabServizio = ({
await utils.servizio.getUserServizi.invalidate(); await utils.servizio.getUserServizi.invalidate();
}, },
}); });
const { mutate: sendEmail } = api.servizio.sendServizioEmail.useMutation({
onError: (error) => {
toast.error(error.message);
},
onSuccess: async () => {
toast.success("Email onboarding inviata con successo");
await utils.servizio.getUserServizi.invalidate();
},
});
const { mutate: update } = api.servizio.updateServizio.useMutation({ const { mutate: update } = api.servizio.updateServizio.useMutation({
onError: (error) => { onError: (error) => {
@ -154,31 +143,6 @@ export const TabServizio = ({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem asChild>
<Confirm
description="Sei sicuro di voler inviare l'email?"
onConfirm={() => {
sendEmail({
servizioId: servizio.servizio_id,
userId: userId,
});
}}
title="Invia email"
>
<Button
aria-label="Invia Email Servizio"
className="flex w-full justify-start gap-3 px-2 font-normal"
//disabled={onboardDone}
size="sm"
variant="ghost"
>
<Mail className="size-5 stroke-foreground" />
{servizio.isSent
? "Re-Invia all'utente"
: "Invia all'utente"}
</Button>
</Confirm>
</DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link <Link
aria-label="Apri Onboarding Servizio" aria-label="Apri Onboarding Servizio"

View file

@ -1,4 +1,5 @@
import { Eye, EyeOff } from "lucide-react"; import { Eye, EyeOff } from "lucide-react";
import { useRouter } from "next/router";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { import {
@ -26,18 +27,34 @@ export const ChangePasswordForm = () => {
strengthScore, strengthScore,
toggleVisibility, toggleVisibility,
} = useStrongPassword(); } = useStrongPassword();
const router = useRouter();
const ChangePasswordFormSchema = z const ChangePasswordFormSchema = z
.object({ .object({
newpassword: z.string(), oldPsw: z.string().min(1, "Inserisci la password corrente"),
oldpassword: z.string(), newPsw: z.string(),
confirmPsw: z.string().min(1, "Ripeti la nuova password"),
}) })
.superRefine(({ newpassword }, refinementContext) => { .superRefine(({ oldPsw, newPsw, confirmPsw }, refinementContext) => {
passwordRefine({ passwordRefine({
password: newpassword, password: newPsw,
path: "newpassword", path: "newPsw",
refinementContext, refinementContext,
}); });
if (oldPsw === newPsw) {
refinementContext.addIssue({
code: "custom",
message: "La nuova password deve essere diversa da quella corrente",
path: ["newPsw"],
});
}
if (newPsw !== confirmPsw) {
refinementContext.addIssue({
code: "custom",
message: "Le password non coincidono",
path: ["confirmPsw"],
});
}
}); });
type ChangePasswordFormValues = z.infer<typeof ChangePasswordFormSchema>; type ChangePasswordFormValues = z.infer<typeof ChangePasswordFormSchema>;
@ -48,8 +65,9 @@ export const ChangePasswordForm = () => {
const form = useZodForm(ChangePasswordFormSchema, { const form = useZodForm(ChangePasswordFormSchema, {
defaultValues: { defaultValues: {
newpassword: "", newPsw: "",
oldpassword: "", oldPsw: "",
confirmPsw: "",
}, },
}); });
@ -57,16 +75,17 @@ export const ChangePasswordForm = () => {
onError: (error) => { onError: (error) => {
toast.error(error.message); toast.error(error.message);
}, },
onSuccess: () => { onSuccess: async () => {
toast.success(t.pwReset.pwAggiornata); toast.success(t.pwReset.pwAggiornata);
form.reset(); form.reset();
await router.push("/area-riservata/dashboard");
}, },
}); });
function onSubmit(fields: ChangePasswordFormValues) { function onSubmit(fields: ChangePasswordFormValues) {
mutate({ mutate({
newpassword: fields.newpassword, newpassword: fields.newPsw,
oldpassword: fields.oldpassword, oldpassword: fields.oldPsw,
}); });
} }
@ -75,7 +94,7 @@ export const ChangePasswordForm = () => {
<form className="space-y-8 px-0.5" onSubmit={form.handleSubmit(onSubmit)}> <form className="space-y-8 px-0.5" onSubmit={form.handleSubmit(onSubmit)}>
<FormField <FormField
control={form.control} control={form.control}
name="oldpassword" name="oldPsw"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<div className="flex flex-wrap items-center gap-x-2"> <div className="flex flex-wrap items-center gap-x-2">
@ -96,11 +115,11 @@ export const ChangePasswordForm = () => {
<FormField <FormField
control={form.control} control={form.control}
name="newpassword" name="newPsw"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<div className="flex flex-col gap-x-2"> <div className="flex flex-col gap-x-2">
<FormLabel className="text-neutral-600" htmlFor="newpassword"> <FormLabel className="text-neutral-600" htmlFor="newPsw">
{t.pwReset.newPw} {t.pwReset.newPw}
</FormLabel> </FormLabel>
@ -117,13 +136,13 @@ export const ChangePasswordForm = () => {
<Input <Input
{...field} {...field}
aria-describedby="password-strength" aria-describedby="password-strength"
aria-invalid={form.getFieldState("newpassword").invalid} aria-invalid={form.getFieldState("newPsw").invalid}
autoComplete="new-password" autoComplete="new-password"
className="pe-9" className="pe-9"
id="newpassword" id="newPsw"
onChange={async (e) => { onChange={async (e) => {
field.onChange(e); field.onChange(e);
await form.trigger("newpassword"); await form.trigger("newPsw");
}} }}
placeholder="Password" placeholder="Password"
type={isVisible ? "text" : "password"} type={isVisible ? "text" : "password"}
@ -163,6 +182,27 @@ export const ChangePasswordForm = () => {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="confirmPsw"
render={({ field }) => (
<FormItem>
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="confirmpw">{t.pwReset.confirmPw}</FormLabel>
<FormMessage />
</div>
<FormControl>
<Input
placeholder=""
{...field}
autoComplete="off"
id="confirmpw"
type="password"
/>
</FormControl>
</FormItem>
)}
/>
<Button type="submit">{t.pwReset.aggiorna}</Button> <Button type="submit">{t.pwReset.aggiorna}</Button>
</form> </form>
</Form> </Form>

View file

@ -0,0 +1,92 @@
import toast from "react-hot-toast";
import { z } from "zod/v4";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/custom_ui/form";
import { Button } from "~/components/ui/button";
import { Switch } from "~/components/ui/switch";
import { useZodForm } from "~/lib/zodForm";
import type { UsersId } from "~/schemas/public/Users";
import { api } from "~/utils/api";
export const MustChangePasswordForm = ({
id,
mustChangePassword,
}: {
id: UsersId;
mustChangePassword: boolean;
}) => {
const schema = z.object({
mustChangePassword: z.boolean(),
});
type FormValues = z.infer<typeof schema>;
z.config(z.locales.it());
const form = useZodForm(schema, {
defaultValues: {
mustChangePassword,
},
});
const utils = api.useUtils();
const { mutate } = api.users.editUser.useMutation({
onError: (error) => {
toast.error(error.message);
},
onSuccess: async () => {
toast.success("Impostazione salvata con successo");
await utils.auth.getSession.invalidate();
await utils.users.getUser.invalidate();
await utils.users.getClientProfilo.invalidate({
userId: id,
});
},
});
async function onSubmit(fields: FormValues) {
mutate({
id: id,
data: {
mustChangePassword: fields.mustChangePassword,
},
});
}
return (
<Form {...form}>
<form className="space-y-8 px-0.5" onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="mustChangePassword"
render={({ field }) => (
<FormItem className="w-full">
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="mustChangePassword">
Forza cambio password al prossimo accesso
</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="data-[state=checked]:bg-neutral-700"
id="mustChangePassword"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormMessage />
</div>
</FormItem>
)}
/>
<Button type="submit">Salva</Button>
</form>
</Form>
);
};

View file

@ -12,6 +12,7 @@ import {
import Input from "~/components/custom_ui/input"; import Input from "~/components/custom_ui/input";
import { PhoneInput } from "~/components/phone-input"; import { PhoneInput } from "~/components/phone-input";
import { Button, buttonVariants } from "~/components/ui/button"; import { Button, buttonVariants } from "~/components/ui/button";
import { Switch } from "~/components/ui/switch";
import { useStrongPassword } from "~/hooks/useStrongPassword"; import { useStrongPassword } from "~/hooks/useStrongPassword";
import { generatePassword } from "~/lib/basicPwGenerator"; import { generatePassword } from "~/lib/basicPwGenerator";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
@ -40,6 +41,7 @@ export const FormNewUser = ({
nome: z.string().nonempty("Inserisci un nome valido"), nome: z.string().nonempty("Inserisci un nome valido"),
password: z.string(), password: z.string(),
telefono: z.string().nonempty("Inserisci un numero di telefono"), telefono: z.string().nonempty("Inserisci un numero di telefono"),
mustChangePassword: z.boolean(),
}) })
.superRefine(({ password }, refinementContext) => { .superRefine(({ password }, refinementContext) => {
passwordRefine({ passwordRefine({
@ -57,6 +59,7 @@ export const FormNewUser = ({
nome: "", nome: "",
password: "", password: "",
telefono: "", telefono: "",
mustChangePassword: true,
}; };
const { locale, t } = useTranslation(); const { locale, t } = useTranslation();
@ -86,6 +89,7 @@ export const FormNewUser = ({
nome: fields.nome, nome: fields.nome,
password: fields.password, password: fields.password,
telefono: fields.telefono.replace(/\s/g, ""), telefono: fields.telefono.replace(/\s/g, ""),
mustChangePassword: fields.mustChangePassword,
}); });
} }
@ -222,6 +226,28 @@ export const FormNewUser = ({
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="mustChangePassword"
render={({ field }) => (
<FormItem className="w-full">
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="mustChangePassword">
Forza cambio password al prossimo accesso
</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="data-[state=checked]:bg-neutral-700"
id="mustChangePassword"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormMessage />
</div>
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="telefono" name="telefono"

View file

@ -64,7 +64,7 @@ export const OverridePasswordForm = ({ id }: { id: UsersId }) => {
}, },
}); });
const { mutate } = api.auth.OverridePassword.useMutation({ const { mutate } = api.auth.PasswordOverride.useMutation({
onError: (error) => { onError: (error) => {
toast.error(error.message); toast.error(error.message);
}, },

View file

@ -15,7 +15,7 @@ import { Button } from "~/components/ui/button";
import { Switch } from "~/components/ui/switch"; import { Switch } from "~/components/ui/switch";
import { useZodForm } from "~/lib/zodForm"; import { useZodForm } from "~/lib/zodForm";
import { useTranslation } from "~/providers/I18nProvider"; import { useTranslation } from "~/providers/I18nProvider";
import type { Users, UsersId } from "~/schemas/public/Users"; import type { Users } from "~/schemas/public/Users";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
type ProfileFromAccountProps = Pick< type ProfileFromAccountProps = Pick<
@ -73,25 +73,15 @@ export const ProfileFormAccount = ({
}); });
function onSubmit(fields: ProfileFormValues) { function onSubmit(fields: ProfileFormValues) {
const data: { mutate({
id: UsersId; id,
email: string; data: {
isAdmin?: boolean; ...fields,
nome: string; telefono: fields.telefono?.replace(/\s/g, ""),
cognome: string; username: `${fields.nome?.trim()} ${fields.cognome?.trim()}`,
telefono: string; isAdmin: fields.isAdmin || false,
} = { },
cognome: fields.cognome, });
email: fields.email,
id: id,
nome: fields.nome,
telefono: fields.telefono.replace(/\s/g, ""),
};
if (isAdmin !== undefined) {
data.isAdmin = fields.isAdmin;
}
mutate(data);
} }
return ( return (

View file

@ -1,224 +0,0 @@
import { Eye, EyeOff } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import toast from "react-hot-toast";
import { z } from "zod/v4";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/custom_ui/form";
import Input from "~/components/custom_ui/input";
import { LoadingSpinner } from "~/components/spinner";
import { Button } from "~/components/ui/button";
import { Checkbox } from "~/components/ui/checkbox";
import { useStrongPassword } from "~/hooks/useStrongPassword";
import { cn } from "~/lib/utils";
import { useZodForm } from "~/lib/zodForm";
import { useTranslation } from "~/providers/I18nProvider";
import type { ServizioServizioId } from "~/schemas/public/Servizio";
import type { Users } from "~/schemas/public/Users";
import { api } from "~/utils/api";
type FormPwSetupProps = {
user: Pick<Users, "id" | "nome" | "cognome" | "email" | "telefono">;
servizioId: ServizioServizioId;
};
export const FormPwSetup = ({ user, servizioId }: FormPwSetupProps) => {
const router = useRouter();
const {
isVisible,
getStrengthColor,
passwordRefine,
getStrengthTxt,
strengthScore,
toggleVisibility,
} = useStrongPassword();
const Schema = z
.object({
checkbox: z.boolean().refine((val) => val === false), // Honeypot field
password: z.string(),
})
.superRefine(({ password }, refinementContext) => {
passwordRefine({
password,
path: "password",
refinementContext,
});
});
type LoginFormValues = z.infer<typeof Schema>;
const defaultValues: LoginFormValues = {
checkbox: false, // Honeypot field
password: "",
};
const { locale, t } = useTranslation();
z.config(z.locales[locale]());
const form = useZodForm(Schema, {
defaultValues: defaultValues,
});
const utils = api.useUtils();
const { mutate, isPending } = api.auth.UpdatePasswordAndLogin.useMutation({
onError: () => {
toast.error(t.auth.login.fail);
},
onSuccess: async () => {
await utils.auth.getSession.invalidate();
setAlreadyRequested(true);
toast.success(t.auth.login.success);
await router.push(`/servizio/onboard/${servizioId}`);
},
});
const [altreadyRequested, setAlreadyRequested] = useState(false);
function onSubmit(fields: LoginFormValues) {
mutate({
id: user.id,
password: fields.password,
});
}
const texts =
locale === "it"
? {
imposta_password: "Imposta una password per accedere al tuo account.",
salva_password: "Salva la password e prosegui",
scegli_password: "Scegli la tua password",
}
: {
imposta_password: "Set a password to access your account.",
salva_password: "Save password and proceed",
scegli_password: "Choose your password",
};
return (
<div className="mx-auto w-full max-w-3xl space-y-8 px-4 py-8">
<div className="my-8 space-y-5">
<h3 className="font-bold text-3xl text-gray-800 sm:text-3xl">
{texts.scegli_password}
</h3>
<p className="text-muted-foreground">{texts.imposta_password}</p>
</div>
<Form {...form}>
<form
className="space-y-8 px-0.5"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<div className="flex flex-col gap-x-2">
<FormLabel className="text-neutral-600" htmlFor="password">
{t.auth.signup.password}
</FormLabel>
<FormMessage
className={cn(
"whitespace-pre-wrap",
getStrengthTxt(strengthScore),
)}
/>
</div>
<FormControl>
<div className="relative">
<Input
{...field}
aria-describedby="password-strength"
aria-invalid={form.getFieldState("password").invalid}
autoComplete="current-password"
className="pe-9"
id="password"
onChange={async (e) => {
field.onChange(e);
await form.trigger("password");
}}
placeholder="Password"
type={isVisible ? "text" : "password"}
/>
<button
aria-controls="password"
aria-label={isVisible ? "Hide password" : "Show password"}
aria-pressed={isVisible}
className="absolute end-0 top-1 flex size-9 items-center justify-center rounded-e-lg text-muted-foreground/80 transition-shadow hover:text-foreground focus-visible:border focus-visible:border-ring focus-visible:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
onClick={toggleVisibility}
type="button"
>
{isVisible ? (
<EyeOff aria-hidden="true" size={16} strokeWidth={2} />
) : (
<Eye aria-hidden="true" size={16} strokeWidth={2} />
)}
</button>
<div
aria-label="Password strength"
aria-valuemax={4}
aria-valuemin={0}
aria-valuenow={strengthScore}
className="mt-3 mb-4 h-1 w-full overflow-hidden rounded-full bg-border"
role="progressbar"
>
<div
className={cn(
"h-full transition-all duration-500 ease-out",
getStrengthColor(strengthScore),
)}
style={{ width: `${(strengthScore / 4) * 100}%` }}
/>
</div>
</div>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="checkbox"
render={({ field }) => (
<FormItem className="hidden">
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="checkbox">Check Me</FormLabel>
<FormMessage />
</div>
<FormControl>
<Checkbox
checked={field.value}
id="checkbox"
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<Button
className="w-full rounded-lg bg-indigo-600 px-4 py-2 font-medium text-white duration-150 hover:bg-indigo-500 active:bg-indigo-600"
disabled={isPending || altreadyRequested}
type="submit"
>
{isPending ? (
<div className="flex items-center justify-center">
<LoadingSpinner size={25} />
</div>
) : (
<span>{texts.salva_password}</span>
)}
</Button>
</form>
</Form>
</div>
);
};

View file

@ -1186,6 +1186,7 @@ The Stable Rent service, ideal for those with demonstrable work references and f
email: "Email", email: "Email",
get_link: "Get the reset link", get_link: "Get the reset link",
newPw: "New password", newPw: "New password",
confirmPw: "Repeat new password",
notvalid_cta: "Request a new link", notvalid_cta: "Request a new link",
notvalid_desc: notvalid_desc:
"The link you clicked is not valid or has expired, request a new one", "The link you clicked is not valid or has expired, request a new one",

View file

@ -1184,6 +1184,7 @@ export const it: LangDict = {
email: "Email", email: "Email",
get_link: "Ricevi link per il reset", get_link: "Ricevi link per il reset",
newPw: "Nuova password", newPw: "Nuova password",
confirmPw: "Ripeti nuova password",
notvalid_cta: "Richiedi un nuovo link", notvalid_cta: "Richiedi un nuovo link",
notvalid_desc: "Il link per il reset password non è valido o è scaduto", notvalid_desc: "Il link per il reset password non è valido o è scaduto",
notvalid_title: "Link non valido", notvalid_title: "Link non valido",

View file

@ -468,6 +468,7 @@ export type LangDict = {
titolo: string; titolo: string;
oldPw: string; oldPw: string;
newPw: string; newPw: string;
confirmPw: string;
ripetiPw: string; ripetiPw: string;
pwDiverse: string; pwDiverse: string;
email: string; email: string;

View file

@ -1,15 +1,16 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { authMiddleware } from "~/middlewares/auth_middleware"; import { authMiddleware } from "~/middlewares/auth";
import { apisMiddleware } from "./middlewares/api_middleware"; import { apisMiddleware } from "./middlewares/api";
import { pswChangeMiddleware } from "./middlewares/psw_change";
export async function middleware(request: NextRequest) { export async function middleware(request: NextRequest) {
//console.log("Middleware triggered for:", request.nextUrl.pathname); //console.log("Middleware triggered for:", request.nextUrl.pathname);
const { pathname } = request.nextUrl;
if (pathname.startsWith("/storage-api/")) {
return await apisMiddleware(request);
}
return (await authMiddleware(request)) ?? NextResponse.next(); return await chainMiddlewares(request, [
apisMiddleware,
pswChangeMiddleware,
authMiddleware,
]);
} }
export const config = { export const config = {
@ -28,3 +29,27 @@ export const config = {
}, },
], ],
}; };
// Type for middleware functions
export type MiddlewareFn = (
request: NextRequest,
) => Promise<NextResponse | null | undefined> | NextResponse | null | undefined;
// Helper to chain middlewares in order
async function chainMiddlewares(
request: NextRequest,
middlewares: MiddlewareFn[],
): Promise<NextResponse> {
for (const middleware of middlewares) {
const result = await middleware(request);
// If middleware returned a response, short-circuit
if (result) {
return result;
}
// Otherwise, continue to next middleware
}
// All middlewares passed, continue to the app
return NextResponse.next();
}

View file

@ -1,15 +1,15 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { env } from "~/env"; import { env } from "~/env";
import type { MiddlewareFn } from "~/middleware";
import { TOKEN_CONFIG } from "~/server/auth/configs"; import { TOKEN_CONFIG } from "~/server/auth/configs";
import { verifyToken } from "~/server/auth/jwt"; import { verifyToken } from "~/server/auth/jwt";
const STALE_KEY = "stale-resource"; const STALE_KEY = "stale-resource";
export const apisMiddleware = async (req: NextRequest) => { export const apisMiddleware: MiddlewareFn = async (req: NextRequest) => {
const { pathname, searchParams } = req.nextUrl; const { pathname, searchParams } = req.nextUrl;
// Only handle storage API routes
if (!pathname.startsWith("/storage-api/")) { if (!pathname.startsWith("/storage-api/")) {
return null; // Pass to next middleware return;
} }
const params = new URLSearchParams(searchParams); const params = new URLSearchParams(searchParams);

View file

@ -1,10 +1,10 @@
import { add } from "date-fns"; import { add } from "date-fns";
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import type { MiddlewareFn } from "~/middleware";
import { TOKEN_CONFIG } from "~/server/auth/configs"; import { TOKEN_CONFIG } from "~/server/auth/configs";
import { signToken, verifyToken } from "~/server/auth/jwt"; import { signToken, verifyToken } from "~/server/auth/jwt";
export const authMiddleware = async (req: NextRequest) => { export const authMiddleware: MiddlewareFn = async (req: NextRequest) => {
const path = req.nextUrl.pathname; const path = req.nextUrl.pathname;
const refreshToken = req.cookies.get( const refreshToken = req.cookies.get(
@ -51,7 +51,7 @@ export const authMiddleware = async (req: NextRequest) => {
new URL("/area-riservata/dashboard", req.nextUrl), new URL("/area-riservata/dashboard", req.nextUrl),
); );
} }
return NextResponse.next(); return;
} }
} else { } else {
if (path.startsWith("/login")) { if (path.startsWith("/login")) {
@ -92,7 +92,7 @@ export const authMiddleware = async (req: NextRequest) => {
} }
if (path.startsWith("/auth/new-password-reset")) { if (path.startsWith("/auth/new-password-reset")) {
if (!accessToken) { if (!accessToken) {
return NextResponse.next(); return;
} }
return NextResponse.redirect(new URL("/", req.nextUrl)); return NextResponse.redirect(new URL("/", req.nextUrl));
} }
@ -102,9 +102,19 @@ export const authMiddleware = async (req: NextRequest) => {
if (refreshToken) { if (refreshToken) {
return await refreshAuthToken(req, refreshToken, false); return await refreshAuthToken(req, refreshToken, false);
} }
} else {
const payload = await verifyToken(accessToken);
if (!payload) {
if (refreshToken) {
return await refreshAuthToken(req, refreshToken, false);
}
const response = NextResponse.next();
response.cookies.delete(TOKEN_CONFIG.ACCESS_TOKEN_COOKIE_NAME);
return response;
}
} }
return NextResponse.next(); return;
} }
}; };

View file

@ -0,0 +1,30 @@
import { type NextRequest, NextResponse } from "next/server";
import type { MiddlewareFn } from "~/middleware";
import { TOKEN_CONFIG } from "~/server/auth/configs";
import { verifyToken } from "~/server/auth/jwt";
export const pswChangeMiddleware: MiddlewareFn = async (req: NextRequest) => {
if (req.nextUrl.pathname === "/auth/aggiorna-password") {
return;
}
const accessToken = req.cookies.get(
TOKEN_CONFIG.ACCESS_TOKEN_COOKIE_NAME,
)?.value;
if (!accessToken) {
return;
}
const accessTokenPayload = await verifyToken(accessToken);
if (!accessTokenPayload) {
return;
}
if (accessTokenPayload.mustChangePassword) {
return NextResponse.redirect(
new URL("/auth/aggiorna-password", req.nextUrl),
);
}
return;
};

View file

@ -30,6 +30,7 @@ import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card"; import { Card, CardContent } from "~/components/ui/card";
import { Separator } from "~/components/ui/separator"; import { Separator } from "~/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { MustChangePasswordForm } from "~/forms/FormMustChangePassword";
import { OverridePasswordForm } from "~/forms/FormOverridePassword"; import { OverridePasswordForm } from "~/forms/FormOverridePassword";
import { ProfileFormAccount } from "~/forms/FormProfilo_Account"; import { ProfileFormAccount } from "~/forms/FormProfilo_Account";
import { ProfileFormAnagrafica } from "~/forms/FormProfilo_Anagrafica"; import { ProfileFormAnagrafica } from "~/forms/FormProfilo_Anagrafica";
@ -211,7 +212,15 @@ const EditUser: NextPageWithLayout<EditUserProps> = ({
<TabsContent value="documenti"> <TabsContent value="documenti">
<DocumentiPersonali userData={userData} /> <DocumentiPersonali userData={userData} />
</TabsContent> </TabsContent>
<TabsContent value="password"> <TabsContent className="space-y-4" value="password">
<Card>
<CardContent>
<MustChangePasswordForm
id={userData.id}
mustChangePassword={userData.mustChangePassword}
/>
</CardContent>
</Card>
<Card> <Card>
<CardContent> <CardContent>
<OverridePasswordForm id={userData.id} /> <OverridePasswordForm id={userData.id} />

View file

@ -0,0 +1,236 @@
"use client";
import { Eye, EyeOff, Frown } from "lucide-react";
import type { GetServerSideProps } from "next";
import { useRouter } from "next/router";
import { toast } from "sonner";
import { z } from "zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/custom_ui/form";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { useStrongPassword } from "~/hooks/useStrongPassword";
import { cn } from "~/lib/utils";
import { useZodForm } from "~/lib/zodForm";
import type { NextPageWithLayout } from "~/pages/_app";
import { useTranslation } from "~/providers/I18nProvider";
import { generateSSGHelper } from "~/server/utils/ssgHelper";
import { api } from "~/utils/api";
type Valid = {
valid: true;
token: string;
email: string;
};
type Invalid = {
valid: false;
token: null;
email: null;
};
type InviteTokenProps = Valid | Invalid;
const AcceptInvitePage: NextPageWithLayout<InviteTokenProps> = ({
valid,
token,
email,
}: InviteTokenProps) => {
const {
isVisible,
getStrengthColor,
passwordRefine,
getStrengthTxt,
strengthScore,
toggleVisibility,
} = useStrongPassword();
const router = useRouter();
const schema = z
.object({
newPsw: z.string(),
})
.superRefine(({ newPsw }, refinementContext) => {
passwordRefine({
password: newPsw,
path: "newPsw",
refinementContext,
});
});
type FormValues = z.infer<typeof schema>;
const { locale } = useTranslation();
z.config(z.locales[locale]());
const form = useZodForm(schema, {
defaultValues: {
newPsw: "",
},
});
function onSubmit(fields: FormValues) {
if (!email || !token) return;
acceptInvite({
email,
password: fields.newPsw,
token,
});
}
const { mutate: acceptInvite, isPending } =
api.invite.acceptInvite.useMutation({
onSuccess: async () => {
toast.success("Account creato! Effettua il login.");
await router.push("/login");
},
onError: (error) => {
toast.error(error.message);
},
});
if (!valid) {
return (
<div className="space-y-2 pt-20 text-center">
<Frown className="mx-auto mb-4 size-20 text-muted-foreground" />
<h1 className="font-bold text-xl">Questo link non valido</h1>
<p className="text-muted-foreground">
Il link è scaduto o è già stato utilizzato.
</p>
<p>Contattaci per ricevere una nuova email.</p>
</div>
);
}
return (
<div className="mx-auto max-w-md space-y-6 p-6">
<div>
<h1 className="font-bold text-2xl">Completa la registrazione</h1>
</div>
<Form {...form}>
<form
className="space-y-8 px-0.5"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="newPsw"
render={({ field }) => (
<FormItem>
<div className="flex flex-col gap-x-2">
<FormLabel className="text-neutral-600" htmlFor="newPsw">
Scegli una password
</FormLabel>
<FormMessage
className={cn(
"whitespace-pre-wrap",
getStrengthTxt(strengthScore),
)}
/>
</div>
<FormControl>
<div className="relative">
<Input
{...field}
aria-describedby="password-strength"
aria-invalid={form.getFieldState("newPsw").invalid}
autoComplete="new-password"
className="pe-9"
id="newPsw"
onChange={async (e) => {
field.onChange(e);
await form.trigger("newPsw");
}}
placeholder="Password"
type={isVisible ? "text" : "password"}
/>
<button
aria-controls="password"
aria-label={isVisible ? "Hide password" : "Show password"}
aria-pressed={isVisible}
className="absolute end-0 top-1 flex size-9 items-center justify-center rounded-e-lg text-muted-foreground/80 transition-shadow hover:text-foreground focus-visible:border focus-visible:border-ring focus-visible:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
onClick={toggleVisibility}
type="button"
>
{isVisible ? (
<EyeOff aria-hidden="true" size={16} strokeWidth={2} />
) : (
<Eye aria-hidden="true" size={16} strokeWidth={2} />
)}
</button>
<div
aria-label="Password strength"
aria-valuemax={4}
aria-valuemin={0}
aria-valuenow={strengthScore}
className="mt-3 mb-4 h-1 w-full overflow-hidden rounded-full bg-border"
role="progressbar"
>
<div
className={cn(
"h-full transition-all duration-500 ease-out",
getStrengthColor(strengthScore),
)}
style={{ width: `${(strengthScore / 4) * 100}%` }}
/>
</div>
</div>
</FormControl>
</FormItem>
)}
/>
<Button disabled={!form.formState.isValid || isPending} type="submit">
Procedi
</Button>
</form>
</Form>
</div>
);
};
export default AcceptInvitePage;
export const getServerSideProps = (async (context) => {
const token = context.params?.token;
if (typeof token !== "string") {
return {
redirect: {
destination: "/auth/nonvalid-password-reset-token",
permanent: false,
},
};
}
const helper = generateSSGHelper();
const valid = await helper.invite.verifyInvite.fetch({ token });
if (valid.valid) {
return {
props: {
valid: true,
token,
email: valid.email as string,
},
};
}
return {
props: {
valid: false,
token: null,
email: null,
},
};
}) satisfies GetServerSideProps<InviteTokenProps>;

View file

@ -0,0 +1,34 @@
import type { NextPage } from "next";
import Head from "next/head";
import { LoadingPage } from "~/components/loading";
import { Status500 } from "~/components/status-page";
import { ChangePasswordForm } from "~/forms/FormChangePassword";
import { useTranslation } from "~/providers/I18nProvider";
import { useSession } from "~/providers/SessionProvider";
const AggiornaPasswordPage: NextPage = () => {
const { t } = useTranslation();
const session = useSession();
if (session.status === "pending") {
return <LoadingPage />;
}
if (session.status === "error" || !session.user) {
window.location.reload();
return <Status500 />;
}
return (
<>
<Head>
<title>{t.heads.login_titolo}</title>
<meta content={t.heads.login_description} name="description" />
</Head>
<main className="flex h-full w-full flex-col items-center justify-center px-4">
<div className="w-full max-w-md py-28 text-gray-600 md:py-44">
<ChangePasswordForm />
</div>
</main>
</>
);
};
export default AggiornaPasswordPage;

View file

@ -1,138 +0,0 @@
import { ArrowRight } from "lucide-react";
import type { GetServerSideProps } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
import { ComeFunziona } from "~/components/come_funziona";
import { AreaRiservataLayout } from "~/components/Layout";
import { LoadingPage } from "~/components/loading";
import { PrezziShow } from "~/components/prezzi";
import { Status500 } from "~/components/status-page";
import { Button } from "~/components/ui/button";
import { FormPwSetup } from "~/forms/FormPwSetup";
import type { NextPageWithLayout } from "~/pages/_app";
import { useTranslation } from "~/providers/I18nProvider";
import type { ServizioServizioId } from "~/schemas/public/Servizio";
import { generateSSGHelper } from "~/server/utils/ssgHelper";
import { zServizioId } from "~/server/utils/zod_types";
import { api } from "~/utils/api";
type PreOnboardServizioProps = {
servizioId: ServizioServizioId;
};
const PreOnboardServizio: NextPageWithLayout<PreOnboardServizioProps> = ({
servizioId,
}: PreOnboardServizioProps) => {
const router = useRouter();
const { data, isLoading } = api.servizio.getPreOnboardData.useQuery({
servizioId: servizioId,
});
const [discalimerAccepted, setDisclaimerAccepted] = useState(false);
if (isLoading) {
return <LoadingPage />;
}
if (!data) {
return <Status500 />;
}
const { locale } = useTranslation();
const texts =
locale === "it"
? {
consulta_faq: "Consulta le domande frequenti",
hai_domande: "Hai altre domande sul servizio?",
procedi: "Procedi",
}
: {
consulta_faq: "Check the FAQs",
hai_domande: "Do you have any other questions about the service?",
procedi: "Proceed",
};
return (
<main className="mx-auto w-full max-w-6xl px-2 py-5 md:px-8">
{discalimerAccepted ? (
<FormPwSetup servizioId={servizioId} user={data.userData} />
) : (
<div className="space-y-4 pb-10">
<ComeFunziona />
<PrezziShow />
<div className="flex w-full flex-wrap items-center justify-center gap-4 py-4">
<h3 className="font-semibold text-xl">{texts.hai_domande}</h3>
<Link href="/guida#faq" target="_blank">
<Button variant="outline">{texts.consulta_faq}</Button>
</Link>
</div>
<div className="flex justify-center">
<Button
className="text-xl"
onClick={async () => {
if (data.hasPrevious) {
await router.push({
pathname: "/login",
query: { redirect: `/servizio/onboard/${servizioId}` },
});
} else {
setDisclaimerAccepted(true);
}
}}
size="xl"
variant="success"
>
{texts.procedi}
<ArrowRight />
</Button>
</div>
</div>
)}
</main>
);
};
PreOnboardServizio.getLayout = function getLayout(page) {
return (
<AreaRiservataLayout ignoreSessionCheck noSidebar>
{page}
</AreaRiservataLayout>
);
};
export default PreOnboardServizio;
export const getServerSideProps = (async (context) => {
const id = context.params?.servizioId;
if (typeof id !== "string") {
console.error("Error: servizioId is not a string");
return {
redirect: {
destination: "/500",
permanent: false,
},
};
}
const parsed = zServizioId.safeParse(id);
if (!parsed.success) {
console.error("Error parsing servizioId", parsed.error);
return {
redirect: {
destination: "/500",
permanent: false,
},
};
}
const trpc = generateSSGHelper();
await trpc.servizio.getPreOnboardData.prefetch({
servizioId: parsed.data,
});
return {
props: {
servizioId: parsed.data,
trpcState: trpc.dehydrate(),
},
};
}) satisfies GetServerSideProps<PreOnboardServizioProps>;

View file

@ -9,6 +9,7 @@ import type { default as ChatsEtichetteTable } from './ChatsEtichette';
import type { default as TestiEStringheTable } from './TestiEStringhe'; import type { default as TestiEStringheTable } from './TestiEStringhe';
import type { default as UsersStorageTable } from './UsersStorage'; import type { default as UsersStorageTable } from './UsersStorage';
import type { default as ServizioAnnunciTable } from './ServizioAnnunci'; import type { default as ServizioAnnunciTable } from './ServizioAnnunci';
import type { default as UserInvitesTable } from './UserInvites';
import type { default as EmailsTable } from './Emails'; import type { default as EmailsTable } from './Emails';
import type { default as PaymentsTable } from './Payments'; import type { default as PaymentsTable } from './Payments';
import type { default as PotenzialiGroupsTable } from './PotenzialiGroups'; import type { default as PotenzialiGroupsTable } from './PotenzialiGroups';
@ -47,6 +48,8 @@ export default interface PublicSchema {
servizio_annunci: ServizioAnnunciTable; servizio_annunci: ServizioAnnunciTable;
user_invites: UserInvitesTable;
emails: EmailsTable; emails: EmailsTable;
payments: PaymentsTable; payments: PaymentsTable;

View file

@ -0,0 +1,26 @@
// @generated
// This file is automatically generated by Kanel. Do not modify manually.
import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely';
/** Identifier type for public.user_invites */
export type UserInvitesId = string & { __brand: 'public.user_invites' };
/** Represents the table public.user_invites */
export default interface UserInvitesTable {
id: ColumnType<UserInvitesId, UserInvitesId | undefined, UserInvitesId>;
email: ColumnType<string, string, string>;
token: ColumnType<string, string, string>;
expires_at: ColumnType<Date, Date | string, Date | string>;
created_at: ColumnType<Date | null, Date | string | null, Date | string | null>;
}
export type UserInvites = Selectable<UserInvitesTable>;
export type NewUserInvites = Insertable<UserInvitesTable>;
export type UserInvitesUpdate = Updateable<UserInvitesTable>;

View file

@ -34,13 +34,9 @@ export default interface UsersTable {
isAdminMade: ColumnType<boolean | null, boolean | null, boolean | null>; isAdminMade: ColumnType<boolean | null, boolean | null, boolean | null>;
isVerified: ColumnType<boolean, boolean | undefined, boolean>;
verificationToken: ColumnType<string | null, string | null, string | null>;
verificationTokenExpires: ColumnType<Date | null, Date | string | null, Date | string | null>;
salt: ColumnType<string, string, string>; salt: ColumnType<string, string, string>;
mustChangePassword: ColumnType<boolean, boolean | undefined, boolean>;
} }
export type Users = Selectable<UsersTable>; export type Users = Selectable<UsersTable>;

View file

@ -22,6 +22,7 @@ import { bannersRouter } from "./routers/banners";
import { catastoRouter } from "./routers/catasto"; import { catastoRouter } from "./routers/catasto";
import { etichetteRouter } from "./routers/etichette"; import { etichetteRouter } from "./routers/etichette";
import { flagsRouter } from "./routers/flags"; import { flagsRouter } from "./routers/flags";
import { inviteRouter } from "./routers/invite";
import { potenzialiRouter } from "./routers/potenziali"; import { potenzialiRouter } from "./routers/potenziali";
import { revalidationRouter } from "./routers/revalidation"; import { revalidationRouter } from "./routers/revalidation";
import { stringsRouter } from "./routers/strings"; import { stringsRouter } from "./routers/strings";
@ -57,5 +58,6 @@ export const appRouter = createTRPCRouter({
strings: stringsRouter, strings: stringsRouter,
revalidation: revalidationRouter, revalidation: revalidationRouter,
potenziali: potenzialiRouter, potenziali: potenzialiRouter,
invite: inviteRouter,
}); });
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;

View file

@ -41,6 +41,7 @@ export const authRouter = createTRPCRouter({
nome: z.string().nonempty("Inserisci un nome valido"), nome: z.string().nonempty("Inserisci un nome valido"),
password: zStrongPassword, password: zStrongPassword,
telefono: z.string().nonempty("Inserisci un numero di telefono"), telefono: z.string().nonempty("Inserisci un numero di telefono"),
mustChangePassword: z.boolean(),
}), }),
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
@ -56,6 +57,7 @@ export const authRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
return await passwordChange({ return await passwordChange({
userId: ctx.session.id, userId: ctx.session.id,
ctx,
...input, ...input,
}); });
}), }),
@ -94,7 +96,7 @@ export const authRouter = createTRPCRouter({
return "logout success"; return "logout success";
}), }),
OverridePassword: adminProcedure PasswordOverride: adminProcedure
.input( .input(
z.object({ z.object({
id: zUserId, id: zUserId,
@ -102,7 +104,7 @@ export const authRouter = createTRPCRouter({
}), }),
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
return await passwordOverrideHandler({ input }); return await passwordOverrideHandler({ ...input });
}), }),
pwResetGenTokenMail: publicProcedure pwResetGenTokenMail: publicProcedure
.input(z.object({ email: z.string() })) .input(z.object({ email: z.string() }))
@ -140,21 +142,4 @@ export const authRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
return await swapAuth({ ctx, userId: input.id }); return await swapAuth({ ctx, userId: input.id });
}), }),
UpdatePasswordAndLogin: publicProcedure
.input(
z.object({
id: zUserId,
password: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
await passwordOverrideHandler({ input });
if (!ctx.session) {
await swapAuth({
ctx: ctx,
userId: input.id,
});
}
return true;
}),
}); });

View file

@ -0,0 +1,101 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { env } from "~/env";
import {
adminProcedure,
createTRPCRouter,
publicProcedure,
} from "~/server/api/trpc";
import {
consumeInviteToken,
createInviteToken,
sendInvitoEmail,
verifyInviteToken,
} from "~/server/controllers/invites.controller";
import { editAccountHandler } from "~/server/controllers/user.controller";
import { db } from "~/server/db";
import { generateSalt, hashPassword } from "~/server/services/password.service";
import { findUser_byEmail, getUser } from "~/server/services/user.service";
import { zUserId } from "~/server/utils/zod_types";
export const inviteRouter = createTRPCRouter({
// Admin sends invite
sendInvite: adminProcedure
.input(
z.object({
id: zUserId,
}),
)
.mutation(async ({ input }) => {
const user = await getUser({ userId: input.id, db });
const token = await createInviteToken(user.email);
const inviteUrl = `${env.BASE_URL}/auth/accetta-invito/${token}`;
await sendInvitoEmail({
userId: input.id,
email: user.email,
nome: user.nome,
inviteUrl,
});
return { success: true };
}),
// Verify invite token (public - user clicking the link)
verifyInvite: publicProcedure
.input(z.object({ token: z.string() }))
.query(async ({ input }) => {
const invite = await verifyInviteToken(input.token);
if (!invite) {
return { valid: false, email: null };
}
return { valid: true, email: invite.email };
}),
// Accept invite and set password
acceptInvite: publicProcedure
.input(
z.object({
email: z.email(),
token: z.string(),
password: z.string().min(8),
}),
)
.mutation(async ({ input }) => {
const user = await findUser_byEmail({ email: input.email });
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Utente non trovato",
});
}
const invite = await verifyInviteToken(input.token);
if (!invite) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Invito non valido o scaduto",
});
}
// Create user with password
const salt = generateSalt();
const hashedPassword = await hashPassword(input.password, salt);
await editAccountHandler({
id: user.id,
data: {
password: hashedPassword,
salt,
},
});
// Mark invite as used
await consumeInviteToken(input.token);
return { success: true };
}),
});

View file

@ -42,7 +42,6 @@ import {
removePagamento, removePagamento,
SbloccaContatti, SbloccaContatti,
SendContactEmail, SendContactEmail,
sendServizioEmail,
setupSaldoConsulenzaServizio, setupSaldoConsulenzaServizio,
setupSaldoServizio, setupSaldoServizio,
updatePagamento, updatePagamento,
@ -314,19 +313,7 @@ export const servizioRouter = createTRPCRouter({
...input, ...input,
}); });
}), }),
sendServizioEmail: protectedProcedure
.input(
z.object({
servizioId: zServizioId,
userId: zUserId,
}),
)
.mutation(async ({ input }) => {
return await sendServizioEmail({
servizioId: input.servizioId,
userId: input.userId,
});
}),
setupSaldoConsulenzaServizio: protectedProcedure setupSaldoConsulenzaServizio: protectedProcedure
.input( .input(
z.object({ z.object({

View file

@ -1,4 +1,5 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
import type { UsersUpdate } from "~/schemas/public/Users";
import type { UsersAnagraficaUpdate } from "~/schemas/public/UsersAnagrafica"; import type { UsersAnagraficaUpdate } from "~/schemas/public/UsersAnagrafica";
import { import {
adminProcedure, adminProcedure,
@ -16,6 +17,7 @@ import {
} from "~/server/controllers/user.controller"; } from "~/server/controllers/user.controller";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { getClientProfilo } from "~/server/services/user.service"; import { getClientProfilo } from "~/server/services/user.service";
import { checkCtxSession } from "~/server/utils/ctxChecker";
import { zUserId } from "~/server/utils/zod_types"; import { zUserId } from "~/server/utils/zod_types";
export const usersRouter = createTRPCRouter({ export const usersRouter = createTRPCRouter({
@ -40,16 +42,13 @@ export const usersRouter = createTRPCRouter({
editUser: protectedProcedure editUser: protectedProcedure
.input( .input(
z.object({ z.object({
cognome: z.string(),
email: z.email(),
id: zUserId, id: zUserId,
isAdmin: z.boolean().optional(), data: z.custom<UsersUpdate>(),
nome: z.string(),
telefono: z.string(),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
return await editAccountHandler({ ctx, input }); checkCtxSession({ ctx });
return await editAccountHandler({ ...input });
}), }),
editUserAnagrafica: protectedProcedure editUserAnagrafica: protectedProcedure
.input( .input(

View file

@ -31,6 +31,7 @@ export const sessionSchema = z.object({
isBlocked: z.boolean(), isBlocked: z.boolean(),
nome: z.string(), nome: z.string(),
username: z.string(), username: z.string(),
mustChangePassword: z.boolean(),
}); });
export type Session = z.infer<typeof sessionSchema>; export type Session = z.infer<typeof sessionSchema>;

View file

@ -27,9 +27,14 @@ export async function signToken(
export async function verifyToken(token: string): Promise<Session | null> { export async function verifyToken(token: string): Promise<Session | null> {
try { try {
const { payload } = await jwtVerify(token, secret); const { payload } = await jwtVerify(token, secret);
return sessionSchema.parse(payload.payload); const parse = sessionSchema.safeParse(payload.payload);
if (!parse.success) {
//console.error("JWT payload invalid:", parse.error);
return null;
}
return parse.data;
} catch (e) { } catch (e) {
console.error("JWT verification error:", (e as Error).message); console.error("JWT verification Internal Error:", (e as Error).message);
return null; return null;
} }
} }

View file

@ -26,12 +26,14 @@ export const createUserAdmin = async ({
nome, nome,
cognome, cognome,
telefono, telefono,
mustChangePassword,
}: { }: {
email: string; email: string;
password: string; password: string;
nome: string; nome: string;
cognome: string; cognome: string;
telefono: string; telefono: string;
mustChangePassword: boolean;
}) => { }) => {
const banned = await isBanned({ data: { email }, db }); const banned = await isBanned({ data: { email }, db });
if (banned) { if (banned) {
@ -49,9 +51,9 @@ export const createUserAdmin = async ({
cognome: cognome.trim(), cognome: cognome.trim(),
email, email,
isAdminMade: true, isAdminMade: true,
isVerified: true,
nome: nome.trim(), nome: nome.trim(),
password: hashedPassword, password: hashedPassword,
mustChangePassword,
salt, salt,
telefono, telefono,
username: `${nome.trim()} ${cognome.trim()}`, username: `${nome.trim()} ${cognome.trim()}`,
@ -124,6 +126,7 @@ export const logIn = async ({
isBlocked: user.isBlocked, isBlocked: user.isBlocked,
nome: user.nome, nome: user.nome,
username: user.username, username: user.username,
mustChangePassword: user.mustChangePassword,
}; };
const accessToken = await signToken( const accessToken = await signToken(
@ -241,11 +244,20 @@ export const passwordChange = async ({
userId, userId,
oldpassword, oldpassword,
newpassword, newpassword,
ctx,
}: { }: {
userId: UsersId; userId: UsersId;
oldpassword: string; oldpassword: string;
newpassword: string; newpassword: string;
ctx: Context;
}) => { }) => {
const { req, res } = ctx;
if (!req || !res) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Request/Response context mancante",
});
}
const dbUser = await findUser_byId({ id: userId }); const dbUser = await findUser_byId({ id: userId });
if (!dbUser) { if (!dbUser) {
throw new TRPCError({ throw new TRPCError({
@ -280,10 +292,51 @@ export const passwordChange = async ({
.set({ .set({
password: hashedPassword, password: hashedPassword,
salt, salt,
mustChangePassword: false,
}) })
.where("id", "=", userId) .where("id", "=", userId)
.execute(); .execute();
// Refresh tokens with updated mustChangePassword value
const payload = {
email: dbUser.email,
id: dbUser.id,
isAdmin: dbUser.isAdmin,
isBlocked: dbUser.isBlocked,
nome: dbUser.nome,
username: dbUser.username,
mustChangePassword: false,
};
const accessToken = await signToken(
payload,
TOKEN_CONFIG.ACCESS_TOKEN_EXPIRY,
"accessToken",
);
const refreshToken = await signToken(
payload,
TOKEN_CONFIG.REFRESH_TOKEN_EXPIRY,
"refreshToken",
);
await setCookie(TOKEN_CONFIG.ACCESS_TOKEN_COOKIE_NAME, accessToken, {
...TOKEN_CONFIG.COOKIE_OPTIONS,
expires: add(new Date(), {
hours: TOKEN_CONFIG.ACCESS_TOKEN_COOKIE_EXPIRY,
}),
req,
res,
});
await setCookie(TOKEN_CONFIG.REFRESH_TOKEN_COOKIE_NAME, refreshToken, {
...TOKEN_CONFIG.COOKIE_OPTIONS,
expires: add(new Date(), {
days: TOKEN_CONFIG.REFRESH_TOKEN_COOKIE_EXPIRY,
}),
req,
res,
});
return { return {
status: "success", status: "success" as const,
}; };
}; };

View file

@ -0,0 +1,101 @@
import crypto from "node:crypto";
import { TRPCError } from "@trpc/server";
import type { UsersId } from "~/schemas/public/Users";
import { db } from "~/server/db";
import { NewMail } from "../services/mailer";
const INVITE_EXPIRY_HOURS = 48;
export async function createInviteToken(email: string) {
// Generate a secure random token
const token = crypto.randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + INVITE_EXPIRY_HOURS * 60 * 60 * 1000);
try {
// Store in database
await db
.insertInto("user_invites")
.values({
email,
token,
expires_at: expiresAt,
})
.execute();
} catch (e) {
console.error("Error creating invite token:", e);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Errore durante la creazione del token di invito.: ${(e as Error).message}`,
});
}
return token;
}
export async function verifyInviteToken(token: string) {
try {
const invite = await db
.selectFrom("user_invites")
.selectAll()
.where("token", "=", token)
.where("expires_at", ">", new Date())
.executeTakeFirst();
if (!invite) {
return null;
}
return invite;
} catch (e) {
console.error("Error verifying invite token:", e);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Errore durante la verifica del token di invito.: ${(e as Error).message}`,
});
}
}
export async function consumeInviteToken(token: string) {
try {
await db.deleteFrom("user_invites").where("token", "=", token).execute();
} catch (e) {
console.error("Error consuming invite token:", e);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Errore durante il consumo del token di invito.: ${(e as Error).message}`,
});
}
}
export const sendInvitoEmail = async ({
userId,
email,
inviteUrl,
nome,
}: {
userId: UsersId;
email: string;
inviteUrl: string;
nome: string;
}) => {
try {
await NewMail({
template: {
mailType: "invito",
props: {
inviteUrl,
nome,
},
},
mail: {
subject: "Procedi ora su Infoalloggi.it",
to: email,
},
userId,
});
return true;
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error sending email: ${(e as Error).message}`,
});
}
};

View file

@ -1157,77 +1157,6 @@ export const InteractionLogicTipologia = async ({
}; };
}; };
export const sendServizioEmail = async ({
servizioId,
userId,
}: {
servizioId: ServizioServizioId;
userId: UsersId;
}) => {
try {
const user = await db
.selectFrom("users")
.select(["email", "nome"])
.where("id", "=", userId)
.executeTakeFirstOrThrow();
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const annunci = await db
.selectFrom("servizio_annunci")
.innerJoin("annunci", "annunci.id", "servizio_annunci.annunci_id")
.select((_eb) => [
"annunci.titolo_it",
"annunci.codice",
"annunci.prezzo",
withImages(),
withVideos(),
])
.where("servizio_annunci.servizio_id", "=", servizioId)
.execute();
const { email, nome } = user;
await NewMail({
template: {
mailType: "onboardingServizio",
props: {
annunci: annunci.map((annuncio) => ({
codice: annuncio.codice,
immagine: annuncio?.images
? annuncio.images[0]?.img || "fallback"
: "fallback",
prezzo: annuncio.prezzo,
titolo: annuncio.titolo_it,
})),
nome,
token: servizioId,
},
},
mail: {
subject: "Procedi ora su Infoalloggi.it",
to: email,
},
userId,
});
await db
.updateTable("servizio")
.set({
isSent: true,
})
.where("servizio_id", "=", servizioId)
.executeTakeFirstOrThrow();
return true;
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error sending email: ${(e as Error).message}`,
});
}
};
export const userSendConfermaIntent = async ({ export const userSendConfermaIntent = async ({
servizioId, servizioId,
annuncioId, annuncioId,

View file

@ -3,51 +3,25 @@ import type { UsersId, UsersUpdate } from "~/schemas/public/Users";
import type { UsersAnagraficaUpdate } from "~/schemas/public/UsersAnagrafica"; import type { UsersAnagraficaUpdate } from "~/schemas/public/UsersAnagrafica";
import type { Context } from "~/server/api/trpc"; import type { Context } from "~/server/api/trpc";
import { db, type Querier } from "~/server/db"; import { db, type Querier } from "~/server/db";
import { checkCtxAdmin, checkCtxSession } from "~/server/utils/ctxChecker"; import { checkCtxAdmin } from "~/server/utils/ctxChecker";
type EditAccountInput = {
id: UsersId;
nome: string;
cognome: string;
email: string;
telefono: string;
isAdmin?: boolean;
};
export const editAccountHandler = async ({ export const editAccountHandler = async ({
ctx, id,
input: { id, nome, cognome, email, telefono, isAdmin }, data,
}: { }: {
ctx: Context; id: UsersId;
input: EditAccountInput; data: UsersUpdate;
}) => { }) => {
checkCtxSession({ ctx });
try { try {
const data: UsersUpdate = { await db
cognome: cognome.trim(),
email: email.trim(),
nome: nome.trim(),
telefono: telefono.replace(/\s/g, ""),
username: `${nome.trim()} ${cognome.trim()}`,
};
if (isAdmin !== undefined) {
data.isAdmin = isAdmin;
}
const user = await db
.updateTable("users") .updateTable("users")
.set(data) .set({
...data,
})
.where("id", "=", id) .where("id", "=", id)
.returningAll()
.execute(); .execute();
if (!user) { return "success";
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "errore modifica utente",
});
}
return user;
} catch { } catch {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",

View file

@ -130,12 +130,11 @@ export const pwResetSendLinkHandler = async ({ email }: { email: string }) => {
}; };
export const passwordOverrideHandler = async ({ export const passwordOverrideHandler = async ({
input: { id, password }, id,
password,
}: { }: {
input: { id: UsersId;
id: UsersId; password: string;
password: string;
};
}) => { }) => {
const salt = generateSalt(); const salt = generateSalt();
const hashedPassword = await hashPassword(password, salt); const hashedPassword = await hashPassword(password, salt);

View file

@ -10,6 +10,8 @@ import EmailContattoInteressato, {
} from "emails/email-interessato"; } from "emails/email-interessato";
import type { Generic_NewMail } from "emails/gereric-email"; import type { Generic_NewMail } from "emails/gereric-email";
import Generic from "emails/gereric-email"; import Generic from "emails/gereric-email";
import type { Invito_NewMail } from "emails/invito";
import Invito from "emails/invito";
import OnboardingServizio, { import OnboardingServizio, {
type OnboardingServizio_NewMail, type OnboardingServizio_NewMail,
} from "emails/onboarding-servizio"; } from "emails/onboarding-servizio";
@ -89,7 +91,8 @@ export type MailsTemplates =
| VerificaEmail_NewMail | VerificaEmail_NewMail
| OnboardingServizio_NewMail | OnboardingServizio_NewMail
| Generic_NewMail | Generic_NewMail
| SchedaContatto_NewMail; | SchedaContatto_NewMail
| Invito_NewMail;
export const genMail = async ({ ...mail }: MailsTemplates) => { export const genMail = async ({ ...mail }: MailsTemplates) => {
let mailComponent: JSX.Element | null = null; let mailComponent: JSX.Element | null = null;
@ -175,6 +178,15 @@ export const genMail = async ({ ...mail }: MailsTemplates) => {
}); });
title = "Scheda Contatto Annuncio"; title = "Scheda Contatto Annuncio";
break; break;
case "invito":
mailComponent = Invito({
...mail.props,
});
title = "Invito al sito";
break;
default:
mailComponent = null;
break;
} }
if (mailComponent === null) { if (mailComponent === null) {
throw new TRPCError({ throw new TRPCError({

View file

@ -30,3 +30,8 @@ export async function comparePasswords({
export function generateSalt() { export function generateSalt() {
return crypto.randomBytes(16).toString("hex").normalize(); return crypto.randomBytes(16).toString("hex").normalize();
} }
export function generateTemporaryPassword() {
// Generate readable temporary password (e.g., "Abc12345")
return crypto.randomBytes(4).toString("hex");
}

View file

@ -40,7 +40,15 @@ export const findUser_byId_MINI = async ({
try { try {
return await db return await db
.selectFrom("users") .selectFrom("users")
.select(["id", "username", "nome", "email", "isAdmin", "isBlocked"]) .select([
"id",
"username",
"nome",
"email",
"isAdmin",
"isBlocked",
"mustChangePassword",
])
.where("id", "=", userId) .where("id", "=", userId)
.executeTakeFirst(); .executeTakeFirst();
} catch (e) { } catch (e) {
@ -119,6 +127,7 @@ export const getClientProfilo = async ({
"isAdmin", "isAdmin",
"isBlocked", "isBlocked",
"isAdminMade", "isAdminMade",
"mustChangePassword",
]) ])
.where("id", "=", userId) .where("id", "=", userId)
.leftJoin("users_anagrafica", "users_anagrafica.userid", "users.id") .leftJoin("users_anagrafica", "users_anagrafica.userid", "users.id")