From 4d7802ccc62321f072bd1229b5721a4e2ca57a41 Mon Sep 17 00:00:00 2001 From: Marco Pedone Date: Sun, 14 Dec 2025 18:57:56 +0100 Subject: [PATCH] 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. --- .../migrations/30_user_semplification.up.sql | 9 + apps/db/migrations/31_magic_link.up.sql | 23 ++ apps/infoalloggi/emails/invito.tsx | 45 ++++ apps/infoalloggi/package-lock.json | 16 +- apps/infoalloggi/package.json | 2 +- .../area-riservata/userViewHeader.tsx | 20 ++ .../src/components/tables/servizio-table.tsx | 36 --- .../src/forms/FormChangePassword.tsx | 72 ++++-- .../src/forms/FormMustChangePassword.tsx | 92 +++++++ apps/infoalloggi/src/forms/FormNewUser.tsx | 26 ++ .../src/forms/FormOverridePassword.tsx | 2 +- .../src/forms/FormProfilo_Account.tsx | 30 +-- apps/infoalloggi/src/forms/FormPwSetup.tsx | 224 ----------------- apps/infoalloggi/src/i18n/en.ts | 1 + apps/infoalloggi/src/i18n/it.ts | 1 + apps/infoalloggi/src/i18n/locales.ts | 1 + apps/infoalloggi/src/middleware.ts | 39 ++- .../middlewares/{api_middleware.ts => api.ts} | 6 +- .../{auth_middleware.ts => auth.ts} | 20 +- .../infoalloggi/src/middlewares/psw_change.ts | 30 +++ .../admin/user-view/edit-user/[userId].tsx | 11 +- .../src/pages/auth/accetta-invito/[token].tsx | 236 ++++++++++++++++++ .../src/pages/auth/aggiorna-password.tsx | 34 +++ .../servizio/pre-onboard/[servizioId].tsx | 138 ---------- .../src/schemas/public/PublicSchema.ts | 3 + .../src/schemas/public/UserInvites.ts | 26 ++ apps/infoalloggi/src/schemas/public/Users.ts | 8 +- apps/infoalloggi/src/server/api/root.ts | 2 + .../src/server/api/routers/auth.ts | 23 +- .../src/server/api/routers/invite.ts | 101 ++++++++ .../src/server/api/routers/servizio.ts | 15 +- .../src/server/api/routers/users.ts | 11 +- apps/infoalloggi/src/server/api/trpc.ts | 1 + apps/infoalloggi/src/server/auth/jwt.ts | 9 +- .../src/server/controllers/auth.controller.ts | 57 ++++- .../server/controllers/invites.controller.ts | 101 ++++++++ .../server/controllers/servizio.controller.ts | 71 ------ .../src/server/controllers/user.controller.ts | 46 +--- .../src/server/services/auth.service.ts | 9 +- .../infoalloggi/src/server/services/mailer.ts | 14 +- .../src/server/services/password.service.ts | 5 + .../src/server/services/user.service.ts | 11 +- 42 files changed, 1004 insertions(+), 623 deletions(-) create mode 100644 apps/db/migrations/30_user_semplification.up.sql create mode 100644 apps/db/migrations/31_magic_link.up.sql create mode 100644 apps/infoalloggi/emails/invito.tsx create mode 100644 apps/infoalloggi/src/forms/FormMustChangePassword.tsx delete mode 100644 apps/infoalloggi/src/forms/FormPwSetup.tsx rename apps/infoalloggi/src/middlewares/{api_middleware.ts => api.ts} (97%) rename apps/infoalloggi/src/middlewares/{auth_middleware.ts => auth.ts} (90%) create mode 100644 apps/infoalloggi/src/middlewares/psw_change.ts create mode 100644 apps/infoalloggi/src/pages/auth/accetta-invito/[token].tsx create mode 100644 apps/infoalloggi/src/pages/auth/aggiorna-password.tsx delete mode 100644 apps/infoalloggi/src/pages/servizio/pre-onboard/[servizioId].tsx create mode 100644 apps/infoalloggi/src/schemas/public/UserInvites.ts create mode 100644 apps/infoalloggi/src/server/api/routers/invite.ts create mode 100644 apps/infoalloggi/src/server/controllers/invites.controller.ts diff --git a/apps/db/migrations/30_user_semplification.up.sql b/apps/db/migrations/30_user_semplification.up.sql new file mode 100644 index 0000000..08e2482 --- /dev/null +++ b/apps/db/migrations/30_user_semplification.up.sql @@ -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; \ No newline at end of file diff --git a/apps/db/migrations/31_magic_link.up.sql b/apps/db/migrations/31_magic_link.up.sql new file mode 100644 index 0000000..45c3c33 --- /dev/null +++ b/apps/db/migrations/31_magic_link.up.sql @@ -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); \ No newline at end of file diff --git a/apps/infoalloggi/emails/invito.tsx b/apps/infoalloggi/emails/invito.tsx new file mode 100644 index 0000000..67dec8c --- /dev/null +++ b/apps/infoalloggi/emails/invito.tsx @@ -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 ( + + <> + + Procedi ora su Infoalloggi.it + +
+ + + Ciao {nome},
+ Accedi ora al servizio di ricerca Infoalloggi, dove potrai andare + a contattare direttamente i proprietari degli immobili + disponibili. +
+
+ +
+ +
+
+
+ + + ); +}; + +export default Invito; diff --git a/apps/infoalloggi/package-lock.json b/apps/infoalloggi/package-lock.json index 7fe3106..eedc3dc 100644 --- a/apps/infoalloggi/package-lock.json +++ b/apps/infoalloggi/package-lock.json @@ -59,7 +59,7 @@ "leaflet": "^1.9.4", "leaflet-defaulticon-compatibility": "^0.1.2", "lucide-react": "^0.536.0", - "next": "15.4.8", + "next": "^15.4.10", "next-themes": "^0.4.6", "nextjs-progressbar": "^0.0.16", "nodemailer": "^7.0.5", @@ -2785,9 +2785,9 @@ } }, "node_modules/@next/env": { - "version": "15.4.8", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.4.8.tgz", - "integrity": "sha512-LydLa2MDI1NMrOFSkO54mTc8iIHSttj6R6dthITky9ylXV2gCGi0bHQjVCtLGRshdRPjyh2kXbxJukDtBWQZtQ==", + "version": "15.4.10", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.4.10.tgz", + "integrity": "sha512-knhmoJ0Vv7VRf6pZEPSnciUG1S4bIhWx+qTYBW/AjxEtlzsiNORPk8sFDCEvqLfmKuey56UB9FL1UdHEV3uBrg==", "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { @@ -14138,13 +14138,13 @@ "license": "MIT" }, "node_modules/next": { - "version": "15.4.8", - "resolved": "https://registry.npmjs.org/next/-/next-15.4.8.tgz", - "integrity": "sha512-jwOXTz/bo0Pvlf20FSb6VXVeWRssA2vbvq9SdrOPEg9x8E1B27C2rQtvriAn600o9hH61kjrVRexEffv3JybuA==", + "version": "15.4.10", + "resolved": "https://registry.npmjs.org/next/-/next-15.4.10.tgz", + "integrity": "sha512-itVlc79QjpKMFMRhP+kbGKaSG/gZM6RCvwhEbwmCNF06CdDiNaoHcbeg0PqkEa2GOcn8KJ0nnc7+yL7EjoYLHQ==", "license": "MIT", "peer": true, "dependencies": { - "@next/env": "15.4.8", + "@next/env": "15.4.10", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", diff --git a/apps/infoalloggi/package.json b/apps/infoalloggi/package.json index 91cb825..40c7677 100644 --- a/apps/infoalloggi/package.json +++ b/apps/infoalloggi/package.json @@ -71,7 +71,7 @@ "leaflet": "^1.9.4", "leaflet-defaulticon-compatibility": "^0.1.2", "lucide-react": "^0.536.0", - "next": "15.4.8", + "next": "15.4.10", "next-themes": "^0.4.6", "nextjs-progressbar": "^0.0.16", "nodemailer": "^7.0.5", diff --git a/apps/infoalloggi/src/components/area-riservata/userViewHeader.tsx b/apps/infoalloggi/src/components/area-riservata/userViewHeader.tsx index 3856c62..9a9d42c 100644 --- a/apps/infoalloggi/src/components/area-riservata/userViewHeader.tsx +++ b/apps/infoalloggi/src/components/area-riservata/userViewHeader.tsx @@ -16,6 +16,7 @@ import { useRouter } from "next/router"; import toast from "react-hot-toast"; import { useUserViewContext } from "~/lib/userViewContext"; import { api } from "~/utils/api"; +import { Confirm } from "../confirm"; import { WhatsAppIcon2 } from "../svgs"; import { Button } from "../ui/button"; import { Separator } from "../ui/separator"; @@ -31,6 +32,14 @@ export const UserViewHeader = () => { 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
Errore
; return (
@@ -50,6 +59,17 @@ export const UserViewHeader = () => { Intestazione )} + { + await sendInvite({ id: data.id }); + }} + title="Vuoi mandare l'email di invito al sito?" + > + +
diff --git a/apps/infoalloggi/src/components/tables/servizio-table.tsx b/apps/infoalloggi/src/components/tables/servizio-table.tsx index df1d826..ab2db12 100644 --- a/apps/infoalloggi/src/components/tables/servizio-table.tsx +++ b/apps/infoalloggi/src/components/tables/servizio-table.tsx @@ -2,13 +2,11 @@ import { Copy, EllipsisVertical, ExternalLink, - Mail, Trash2, Wrench, } from "lucide-react"; import Link from "next/link"; import toast from "react-hot-toast"; -import { Confirm } from "~/components/confirm"; import { LoadingPage } from "~/components/loading"; import { AddAnnuncio } from "~/components/servizio/servizio_actions"; import { Button } from "~/components/ui/button"; @@ -65,15 +63,6 @@ export const TabServizio = ({ 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({ onError: (error) => { @@ -154,31 +143,6 @@ export const TabServizio = ({ - - { - sendEmail({ - servizioId: servizio.servizio_id, - userId: userId, - }); - }} - title="Invia email" - > - - - { strengthScore, toggleVisibility, } = useStrongPassword(); + const router = useRouter(); const ChangePasswordFormSchema = z .object({ - newpassword: z.string(), - oldpassword: z.string(), + oldPsw: z.string().min(1, "Inserisci la password corrente"), + newPsw: z.string(), + confirmPsw: z.string().min(1, "Ripeti la nuova password"), }) - .superRefine(({ newpassword }, refinementContext) => { + .superRefine(({ oldPsw, newPsw, confirmPsw }, refinementContext) => { passwordRefine({ - password: newpassword, - path: "newpassword", + password: newPsw, + path: "newPsw", 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; @@ -48,8 +65,9 @@ export const ChangePasswordForm = () => { const form = useZodForm(ChangePasswordFormSchema, { defaultValues: { - newpassword: "", - oldpassword: "", + newPsw: "", + oldPsw: "", + confirmPsw: "", }, }); @@ -57,16 +75,17 @@ export const ChangePasswordForm = () => { onError: (error) => { toast.error(error.message); }, - onSuccess: () => { + onSuccess: async () => { toast.success(t.pwReset.pwAggiornata); form.reset(); + await router.push("/area-riservata/dashboard"); }, }); function onSubmit(fields: ChangePasswordFormValues) { mutate({ - newpassword: fields.newpassword, - oldpassword: fields.oldpassword, + newpassword: fields.newPsw, + oldpassword: fields.oldPsw, }); } @@ -75,7 +94,7 @@ export const ChangePasswordForm = () => {
(
@@ -96,11 +115,11 @@ export const ChangePasswordForm = () => { (
- + {t.pwReset.newPw} @@ -117,13 +136,13 @@ export const ChangePasswordForm = () => { { field.onChange(e); - await form.trigger("newpassword"); + await form.trigger("newPsw"); }} placeholder="Password" type={isVisible ? "text" : "password"} @@ -163,6 +182,27 @@ export const ChangePasswordForm = () => { )} /> + ( + +
+ {t.pwReset.confirmPw} + +
+ + + +
+ )} + /> diff --git a/apps/infoalloggi/src/forms/FormMustChangePassword.tsx b/apps/infoalloggi/src/forms/FormMustChangePassword.tsx new file mode 100644 index 0000000..4d45b2f --- /dev/null +++ b/apps/infoalloggi/src/forms/FormMustChangePassword.tsx @@ -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; + + 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 ( +
+ + ( + +
+ + Forza cambio password al prossimo accesso + + + + + +
+
+ )} + /> + + + + + ); +}; diff --git a/apps/infoalloggi/src/forms/FormNewUser.tsx b/apps/infoalloggi/src/forms/FormNewUser.tsx index 85012bf..8996383 100644 --- a/apps/infoalloggi/src/forms/FormNewUser.tsx +++ b/apps/infoalloggi/src/forms/FormNewUser.tsx @@ -12,6 +12,7 @@ import { import Input from "~/components/custom_ui/input"; import { PhoneInput } from "~/components/phone-input"; import { Button, buttonVariants } from "~/components/ui/button"; +import { Switch } from "~/components/ui/switch"; import { useStrongPassword } from "~/hooks/useStrongPassword"; import { generatePassword } from "~/lib/basicPwGenerator"; import { cn } from "~/lib/utils"; @@ -40,6 +41,7 @@ export const FormNewUser = ({ nome: z.string().nonempty("Inserisci un nome valido"), password: z.string(), telefono: z.string().nonempty("Inserisci un numero di telefono"), + mustChangePassword: z.boolean(), }) .superRefine(({ password }, refinementContext) => { passwordRefine({ @@ -57,6 +59,7 @@ export const FormNewUser = ({ nome: "", password: "", telefono: "", + mustChangePassword: true, }; const { locale, t } = useTranslation(); @@ -86,6 +89,7 @@ export const FormNewUser = ({ nome: fields.nome, password: fields.password, telefono: fields.telefono.replace(/\s/g, ""), + mustChangePassword: fields.mustChangePassword, }); } @@ -222,6 +226,28 @@ export const FormNewUser = ({ )} /> + ( + +
+ + Forza cambio password al prossimo accesso + + + + + +
+
+ )} + /> { }, }); - const { mutate } = api.auth.OverridePassword.useMutation({ + const { mutate } = api.auth.PasswordOverride.useMutation({ onError: (error) => { toast.error(error.message); }, diff --git a/apps/infoalloggi/src/forms/FormProfilo_Account.tsx b/apps/infoalloggi/src/forms/FormProfilo_Account.tsx index 5f8e3d0..c14330f 100644 --- a/apps/infoalloggi/src/forms/FormProfilo_Account.tsx +++ b/apps/infoalloggi/src/forms/FormProfilo_Account.tsx @@ -15,7 +15,7 @@ import { Button } from "~/components/ui/button"; import { Switch } from "~/components/ui/switch"; import { useZodForm } from "~/lib/zodForm"; 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"; type ProfileFromAccountProps = Pick< @@ -73,25 +73,15 @@ export const ProfileFormAccount = ({ }); function onSubmit(fields: ProfileFormValues) { - const data: { - id: UsersId; - email: string; - isAdmin?: boolean; - nome: string; - cognome: string; - telefono: string; - } = { - 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); + mutate({ + id, + data: { + ...fields, + telefono: fields.telefono?.replace(/\s/g, ""), + username: `${fields.nome?.trim()} ${fields.cognome?.trim()}`, + isAdmin: fields.isAdmin || false, + }, + }); } return ( diff --git a/apps/infoalloggi/src/forms/FormPwSetup.tsx b/apps/infoalloggi/src/forms/FormPwSetup.tsx deleted file mode 100644 index ca4be40..0000000 --- a/apps/infoalloggi/src/forms/FormPwSetup.tsx +++ /dev/null @@ -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; - 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; - - 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 ( -
-
-

- {texts.scegli_password} -

-

{texts.imposta_password}

-
- -
- - ( - -
- - {t.auth.signup.password} - - - -
- - -
- { - field.onChange(e); - await form.trigger("password"); - }} - placeholder="Password" - type={isVisible ? "text" : "password"} - /> - -
-
-
-
- - - )} - /> - ( - -
- Check Me - -
- - - - -
- )} - /> - - - -
- ); -}; diff --git a/apps/infoalloggi/src/i18n/en.ts b/apps/infoalloggi/src/i18n/en.ts index 72a3b08..3c7d573 100644 --- a/apps/infoalloggi/src/i18n/en.ts +++ b/apps/infoalloggi/src/i18n/en.ts @@ -1186,6 +1186,7 @@ The Stable Rent service, ideal for those with demonstrable work references and f email: "Email", get_link: "Get the reset link", newPw: "New password", + confirmPw: "Repeat new password", notvalid_cta: "Request a new link", notvalid_desc: "The link you clicked is not valid or has expired, request a new one", diff --git a/apps/infoalloggi/src/i18n/it.ts b/apps/infoalloggi/src/i18n/it.ts index c255260..ab5449a 100644 --- a/apps/infoalloggi/src/i18n/it.ts +++ b/apps/infoalloggi/src/i18n/it.ts @@ -1184,6 +1184,7 @@ export const it: LangDict = { email: "Email", get_link: "Ricevi link per il reset", newPw: "Nuova password", + confirmPw: "Ripeti nuova password", notvalid_cta: "Richiedi un nuovo link", notvalid_desc: "Il link per il reset password non è valido o è scaduto", notvalid_title: "Link non valido", diff --git a/apps/infoalloggi/src/i18n/locales.ts b/apps/infoalloggi/src/i18n/locales.ts index d8e7ccd..1d97b48 100644 --- a/apps/infoalloggi/src/i18n/locales.ts +++ b/apps/infoalloggi/src/i18n/locales.ts @@ -468,6 +468,7 @@ export type LangDict = { titolo: string; oldPw: string; newPw: string; + confirmPw: string; ripetiPw: string; pwDiverse: string; email: string; diff --git a/apps/infoalloggi/src/middleware.ts b/apps/infoalloggi/src/middleware.ts index 0f54dc6..d9bbc26 100644 --- a/apps/infoalloggi/src/middleware.ts +++ b/apps/infoalloggi/src/middleware.ts @@ -1,15 +1,16 @@ import { type NextRequest, NextResponse } from "next/server"; -import { authMiddleware } from "~/middlewares/auth_middleware"; -import { apisMiddleware } from "./middlewares/api_middleware"; +import { authMiddleware } from "~/middlewares/auth"; +import { apisMiddleware } from "./middlewares/api"; +import { pswChangeMiddleware } from "./middlewares/psw_change"; export async function middleware(request: NextRequest) { //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 = { @@ -28,3 +29,27 @@ export const config = { }, ], }; + +// Type for middleware functions +export type MiddlewareFn = ( + request: NextRequest, +) => Promise | NextResponse | null | undefined; + +// Helper to chain middlewares in order +async function chainMiddlewares( + request: NextRequest, + middlewares: MiddlewareFn[], +): Promise { + 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(); +} diff --git a/apps/infoalloggi/src/middlewares/api_middleware.ts b/apps/infoalloggi/src/middlewares/api.ts similarity index 97% rename from apps/infoalloggi/src/middlewares/api_middleware.ts rename to apps/infoalloggi/src/middlewares/api.ts index 4f8d666..cc043d8 100644 --- a/apps/infoalloggi/src/middlewares/api_middleware.ts +++ b/apps/infoalloggi/src/middlewares/api.ts @@ -1,15 +1,15 @@ import { type NextRequest, NextResponse } from "next/server"; import { env } from "~/env"; +import type { MiddlewareFn } from "~/middleware"; import { TOKEN_CONFIG } from "~/server/auth/configs"; import { verifyToken } from "~/server/auth/jwt"; const STALE_KEY = "stale-resource"; -export const apisMiddleware = async (req: NextRequest) => { +export const apisMiddleware: MiddlewareFn = async (req: NextRequest) => { const { pathname, searchParams } = req.nextUrl; - // Only handle storage API routes if (!pathname.startsWith("/storage-api/")) { - return null; // Pass to next middleware + return; } const params = new URLSearchParams(searchParams); diff --git a/apps/infoalloggi/src/middlewares/auth_middleware.ts b/apps/infoalloggi/src/middlewares/auth.ts similarity index 90% rename from apps/infoalloggi/src/middlewares/auth_middleware.ts rename to apps/infoalloggi/src/middlewares/auth.ts index 60518dd..aabbb25 100644 --- a/apps/infoalloggi/src/middlewares/auth_middleware.ts +++ b/apps/infoalloggi/src/middlewares/auth.ts @@ -1,10 +1,10 @@ import { add } from "date-fns"; import { type NextRequest, NextResponse } from "next/server"; +import type { MiddlewareFn } from "~/middleware"; import { TOKEN_CONFIG } from "~/server/auth/configs"; - 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 refreshToken = req.cookies.get( @@ -51,7 +51,7 @@ export const authMiddleware = async (req: NextRequest) => { new URL("/area-riservata/dashboard", req.nextUrl), ); } - return NextResponse.next(); + return; } } else { if (path.startsWith("/login")) { @@ -92,7 +92,7 @@ export const authMiddleware = async (req: NextRequest) => { } if (path.startsWith("/auth/new-password-reset")) { if (!accessToken) { - return NextResponse.next(); + return; } return NextResponse.redirect(new URL("/", req.nextUrl)); } @@ -102,9 +102,19 @@ export const authMiddleware = async (req: NextRequest) => { if (refreshToken) { 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; } }; diff --git a/apps/infoalloggi/src/middlewares/psw_change.ts b/apps/infoalloggi/src/middlewares/psw_change.ts new file mode 100644 index 0000000..913a92f --- /dev/null +++ b/apps/infoalloggi/src/middlewares/psw_change.ts @@ -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; +}; diff --git a/apps/infoalloggi/src/pages/area-riservata/admin/user-view/edit-user/[userId].tsx b/apps/infoalloggi/src/pages/area-riservata/admin/user-view/edit-user/[userId].tsx index 1ff7f77..8eec04b 100644 --- a/apps/infoalloggi/src/pages/area-riservata/admin/user-view/edit-user/[userId].tsx +++ b/apps/infoalloggi/src/pages/area-riservata/admin/user-view/edit-user/[userId].tsx @@ -30,6 +30,7 @@ import { Button } from "~/components/ui/button"; import { Card, CardContent } from "~/components/ui/card"; import { Separator } from "~/components/ui/separator"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; +import { MustChangePasswordForm } from "~/forms/FormMustChangePassword"; import { OverridePasswordForm } from "~/forms/FormOverridePassword"; import { ProfileFormAccount } from "~/forms/FormProfilo_Account"; import { ProfileFormAnagrafica } from "~/forms/FormProfilo_Anagrafica"; @@ -211,7 +212,15 @@ const EditUser: NextPageWithLayout = ({ - + + + + + + diff --git a/apps/infoalloggi/src/pages/auth/accetta-invito/[token].tsx b/apps/infoalloggi/src/pages/auth/accetta-invito/[token].tsx new file mode 100644 index 0000000..ddd4494 --- /dev/null +++ b/apps/infoalloggi/src/pages/auth/accetta-invito/[token].tsx @@ -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 = ({ + 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; + + 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 ( +
+ + +

Questo link non valido

+

+ Il link è scaduto o è già stato utilizzato. +

+

Contattaci per ricevere una nuova email.

+
+ ); + } + + return ( +
+
+

Completa la registrazione

+
+ +
+ + ( + +
+ + Scegli una password + + + +
+ + +
+ { + field.onChange(e); + await form.trigger("newPsw"); + }} + placeholder="Password" + type={isVisible ? "text" : "password"} + /> + +
+
+
+
+ + + )} + /> + + + + +
+ ); +}; + +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; diff --git a/apps/infoalloggi/src/pages/auth/aggiorna-password.tsx b/apps/infoalloggi/src/pages/auth/aggiorna-password.tsx new file mode 100644 index 0000000..35bec38 --- /dev/null +++ b/apps/infoalloggi/src/pages/auth/aggiorna-password.tsx @@ -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 ; + } + if (session.status === "error" || !session.user) { + window.location.reload(); + return ; + } + return ( + <> + + {t.heads.login_titolo} + + +
+
+ +
+
+ + ); +}; + +export default AggiornaPasswordPage; diff --git a/apps/infoalloggi/src/pages/servizio/pre-onboard/[servizioId].tsx b/apps/infoalloggi/src/pages/servizio/pre-onboard/[servizioId].tsx deleted file mode 100644 index 50917c3..0000000 --- a/apps/infoalloggi/src/pages/servizio/pre-onboard/[servizioId].tsx +++ /dev/null @@ -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 = ({ - servizioId, -}: PreOnboardServizioProps) => { - const router = useRouter(); - const { data, isLoading } = api.servizio.getPreOnboardData.useQuery({ - servizioId: servizioId, - }); - const [discalimerAccepted, setDisclaimerAccepted] = useState(false); - - if (isLoading) { - return ; - } - if (!data) { - return ; - } - 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 ( -
- {discalimerAccepted ? ( - - ) : ( -
- - -
-

{texts.hai_domande}

- - - -
-
- -
-
- )} -
- ); -}; - -PreOnboardServizio.getLayout = function getLayout(page) { - return ( - - {page} - - ); -}; - -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; diff --git a/apps/infoalloggi/src/schemas/public/PublicSchema.ts b/apps/infoalloggi/src/schemas/public/PublicSchema.ts index 97b6ae3..4991e2c 100644 --- a/apps/infoalloggi/src/schemas/public/PublicSchema.ts +++ b/apps/infoalloggi/src/schemas/public/PublicSchema.ts @@ -9,6 +9,7 @@ import type { default as ChatsEtichetteTable } from './ChatsEtichette'; import type { default as TestiEStringheTable } from './TestiEStringhe'; import type { default as UsersStorageTable } from './UsersStorage'; 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 PaymentsTable } from './Payments'; import type { default as PotenzialiGroupsTable } from './PotenzialiGroups'; @@ -47,6 +48,8 @@ export default interface PublicSchema { servizio_annunci: ServizioAnnunciTable; + user_invites: UserInvitesTable; + emails: EmailsTable; payments: PaymentsTable; diff --git a/apps/infoalloggi/src/schemas/public/UserInvites.ts b/apps/infoalloggi/src/schemas/public/UserInvites.ts new file mode 100644 index 0000000..1634940 --- /dev/null +++ b/apps/infoalloggi/src/schemas/public/UserInvites.ts @@ -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; + + email: ColumnType; + + token: ColumnType; + + expires_at: ColumnType; + + created_at: ColumnType; +} + +export type UserInvites = Selectable; + +export type NewUserInvites = Insertable; + +export type UserInvitesUpdate = Updateable; diff --git a/apps/infoalloggi/src/schemas/public/Users.ts b/apps/infoalloggi/src/schemas/public/Users.ts index ea2e2a1..b9d82a4 100644 --- a/apps/infoalloggi/src/schemas/public/Users.ts +++ b/apps/infoalloggi/src/schemas/public/Users.ts @@ -34,13 +34,9 @@ export default interface UsersTable { isAdminMade: ColumnType; - isVerified: ColumnType; - - verificationToken: ColumnType; - - verificationTokenExpires: ColumnType; - salt: ColumnType; + + mustChangePassword: ColumnType; } export type Users = Selectable; diff --git a/apps/infoalloggi/src/server/api/root.ts b/apps/infoalloggi/src/server/api/root.ts index e9adf18..d457fe9 100644 --- a/apps/infoalloggi/src/server/api/root.ts +++ b/apps/infoalloggi/src/server/api/root.ts @@ -22,6 +22,7 @@ import { bannersRouter } from "./routers/banners"; import { catastoRouter } from "./routers/catasto"; import { etichetteRouter } from "./routers/etichette"; import { flagsRouter } from "./routers/flags"; +import { inviteRouter } from "./routers/invite"; import { potenzialiRouter } from "./routers/potenziali"; import { revalidationRouter } from "./routers/revalidation"; import { stringsRouter } from "./routers/strings"; @@ -57,5 +58,6 @@ export const appRouter = createTRPCRouter({ strings: stringsRouter, revalidation: revalidationRouter, potenziali: potenzialiRouter, + invite: inviteRouter, }); export type AppRouter = typeof appRouter; diff --git a/apps/infoalloggi/src/server/api/routers/auth.ts b/apps/infoalloggi/src/server/api/routers/auth.ts index 5d05bfc..d773c63 100644 --- a/apps/infoalloggi/src/server/api/routers/auth.ts +++ b/apps/infoalloggi/src/server/api/routers/auth.ts @@ -41,6 +41,7 @@ export const authRouter = createTRPCRouter({ nome: z.string().nonempty("Inserisci un nome valido"), password: zStrongPassword, telefono: z.string().nonempty("Inserisci un numero di telefono"), + mustChangePassword: z.boolean(), }), ) .mutation(async ({ input }) => { @@ -56,6 +57,7 @@ export const authRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { return await passwordChange({ userId: ctx.session.id, + ctx, ...input, }); }), @@ -94,7 +96,7 @@ export const authRouter = createTRPCRouter({ return "logout success"; }), - OverridePassword: adminProcedure + PasswordOverride: adminProcedure .input( z.object({ id: zUserId, @@ -102,7 +104,7 @@ export const authRouter = createTRPCRouter({ }), ) .mutation(async ({ input }) => { - return await passwordOverrideHandler({ input }); + return await passwordOverrideHandler({ ...input }); }), pwResetGenTokenMail: publicProcedure .input(z.object({ email: z.string() })) @@ -140,21 +142,4 @@ export const authRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { 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; - }), }); diff --git a/apps/infoalloggi/src/server/api/routers/invite.ts b/apps/infoalloggi/src/server/api/routers/invite.ts new file mode 100644 index 0000000..e6bc2ed --- /dev/null +++ b/apps/infoalloggi/src/server/api/routers/invite.ts @@ -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 }; + }), +}); diff --git a/apps/infoalloggi/src/server/api/routers/servizio.ts b/apps/infoalloggi/src/server/api/routers/servizio.ts index 951b96d..83799ea 100644 --- a/apps/infoalloggi/src/server/api/routers/servizio.ts +++ b/apps/infoalloggi/src/server/api/routers/servizio.ts @@ -42,7 +42,6 @@ import { removePagamento, SbloccaContatti, SendContactEmail, - sendServizioEmail, setupSaldoConsulenzaServizio, setupSaldoServizio, updatePagamento, @@ -314,19 +313,7 @@ export const servizioRouter = createTRPCRouter({ ...input, }); }), - sendServizioEmail: protectedProcedure - .input( - z.object({ - servizioId: zServizioId, - userId: zUserId, - }), - ) - .mutation(async ({ input }) => { - return await sendServizioEmail({ - servizioId: input.servizioId, - userId: input.userId, - }); - }), + setupSaldoConsulenzaServizio: protectedProcedure .input( z.object({ diff --git a/apps/infoalloggi/src/server/api/routers/users.ts b/apps/infoalloggi/src/server/api/routers/users.ts index 8111645..b063063 100644 --- a/apps/infoalloggi/src/server/api/routers/users.ts +++ b/apps/infoalloggi/src/server/api/routers/users.ts @@ -1,4 +1,5 @@ import { z } from "zod/v4"; +import type { UsersUpdate } from "~/schemas/public/Users"; import type { UsersAnagraficaUpdate } from "~/schemas/public/UsersAnagrafica"; import { adminProcedure, @@ -16,6 +17,7 @@ import { } from "~/server/controllers/user.controller"; import { db } from "~/server/db"; import { getClientProfilo } from "~/server/services/user.service"; +import { checkCtxSession } from "~/server/utils/ctxChecker"; import { zUserId } from "~/server/utils/zod_types"; export const usersRouter = createTRPCRouter({ @@ -40,16 +42,13 @@ export const usersRouter = createTRPCRouter({ editUser: protectedProcedure .input( z.object({ - cognome: z.string(), - email: z.email(), id: zUserId, - isAdmin: z.boolean().optional(), - nome: z.string(), - telefono: z.string(), + data: z.custom(), }), ) .mutation(async ({ ctx, input }) => { - return await editAccountHandler({ ctx, input }); + checkCtxSession({ ctx }); + return await editAccountHandler({ ...input }); }), editUserAnagrafica: protectedProcedure .input( diff --git a/apps/infoalloggi/src/server/api/trpc.ts b/apps/infoalloggi/src/server/api/trpc.ts index 559422c..9c49295 100644 --- a/apps/infoalloggi/src/server/api/trpc.ts +++ b/apps/infoalloggi/src/server/api/trpc.ts @@ -31,6 +31,7 @@ export const sessionSchema = z.object({ isBlocked: z.boolean(), nome: z.string(), username: z.string(), + mustChangePassword: z.boolean(), }); export type Session = z.infer; diff --git a/apps/infoalloggi/src/server/auth/jwt.ts b/apps/infoalloggi/src/server/auth/jwt.ts index 2673013..2ccaeb8 100644 --- a/apps/infoalloggi/src/server/auth/jwt.ts +++ b/apps/infoalloggi/src/server/auth/jwt.ts @@ -27,9 +27,14 @@ export async function signToken( export async function verifyToken(token: string): Promise { try { 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) { - console.error("JWT verification error:", (e as Error).message); + console.error("JWT verification Internal Error:", (e as Error).message); return null; } } diff --git a/apps/infoalloggi/src/server/controllers/auth.controller.ts b/apps/infoalloggi/src/server/controllers/auth.controller.ts index 2903b3d..40129cf 100644 --- a/apps/infoalloggi/src/server/controllers/auth.controller.ts +++ b/apps/infoalloggi/src/server/controllers/auth.controller.ts @@ -26,12 +26,14 @@ export const createUserAdmin = async ({ nome, cognome, telefono, + mustChangePassword, }: { email: string; password: string; nome: string; cognome: string; telefono: string; + mustChangePassword: boolean; }) => { const banned = await isBanned({ data: { email }, db }); if (banned) { @@ -49,9 +51,9 @@ export const createUserAdmin = async ({ cognome: cognome.trim(), email, isAdminMade: true, - isVerified: true, nome: nome.trim(), password: hashedPassword, + mustChangePassword, salt, telefono, username: `${nome.trim()} ${cognome.trim()}`, @@ -124,6 +126,7 @@ export const logIn = async ({ isBlocked: user.isBlocked, nome: user.nome, username: user.username, + mustChangePassword: user.mustChangePassword, }; const accessToken = await signToken( @@ -241,11 +244,20 @@ export const passwordChange = async ({ userId, oldpassword, newpassword, + ctx, }: { userId: UsersId; oldpassword: 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 }); if (!dbUser) { throw new TRPCError({ @@ -280,10 +292,51 @@ export const passwordChange = async ({ .set({ password: hashedPassword, salt, + mustChangePassword: false, }) .where("id", "=", userId) .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 { - status: "success", + status: "success" as const, }; }; diff --git a/apps/infoalloggi/src/server/controllers/invites.controller.ts b/apps/infoalloggi/src/server/controllers/invites.controller.ts new file mode 100644 index 0000000..880b1c7 --- /dev/null +++ b/apps/infoalloggi/src/server/controllers/invites.controller.ts @@ -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}`, + }); + } +}; diff --git a/apps/infoalloggi/src/server/controllers/servizio.controller.ts b/apps/infoalloggi/src/server/controllers/servizio.controller.ts index dbf0fd6..4f2d110 100644 --- a/apps/infoalloggi/src/server/controllers/servizio.controller.ts +++ b/apps/infoalloggi/src/server/controllers/servizio.controller.ts @@ -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 ({ servizioId, annuncioId, diff --git a/apps/infoalloggi/src/server/controllers/user.controller.ts b/apps/infoalloggi/src/server/controllers/user.controller.ts index 07eb28a..eb07102 100644 --- a/apps/infoalloggi/src/server/controllers/user.controller.ts +++ b/apps/infoalloggi/src/server/controllers/user.controller.ts @@ -3,51 +3,25 @@ import type { UsersId, UsersUpdate } from "~/schemas/public/Users"; import type { UsersAnagraficaUpdate } from "~/schemas/public/UsersAnagrafica"; import type { Context } from "~/server/api/trpc"; import { db, type Querier } from "~/server/db"; -import { checkCtxAdmin, checkCtxSession } from "~/server/utils/ctxChecker"; - -type EditAccountInput = { - id: UsersId; - nome: string; - cognome: string; - email: string; - telefono: string; - isAdmin?: boolean; -}; +import { checkCtxAdmin } from "~/server/utils/ctxChecker"; export const editAccountHandler = async ({ - ctx, - input: { id, nome, cognome, email, telefono, isAdmin }, + id, + data, }: { - ctx: Context; - input: EditAccountInput; + id: UsersId; + data: UsersUpdate; }) => { - checkCtxSession({ ctx }); try { - const data: UsersUpdate = { - 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 + await db .updateTable("users") - .set(data) + .set({ + ...data, + }) .where("id", "=", id) - .returningAll() .execute(); - if (!user) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "errore modifica utente", - }); - } - return user; + return "success"; } catch { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", diff --git a/apps/infoalloggi/src/server/services/auth.service.ts b/apps/infoalloggi/src/server/services/auth.service.ts index fc39853..b31dfc8 100644 --- a/apps/infoalloggi/src/server/services/auth.service.ts +++ b/apps/infoalloggi/src/server/services/auth.service.ts @@ -130,12 +130,11 @@ export const pwResetSendLinkHandler = async ({ email }: { email: string }) => { }; export const passwordOverrideHandler = async ({ - input: { id, password }, + id, + password, }: { - input: { - id: UsersId; - password: string; - }; + id: UsersId; + password: string; }) => { const salt = generateSalt(); const hashedPassword = await hashPassword(password, salt); diff --git a/apps/infoalloggi/src/server/services/mailer.ts b/apps/infoalloggi/src/server/services/mailer.ts index f982305..ad89da7 100644 --- a/apps/infoalloggi/src/server/services/mailer.ts +++ b/apps/infoalloggi/src/server/services/mailer.ts @@ -10,6 +10,8 @@ import EmailContattoInteressato, { } from "emails/email-interessato"; import type { Generic_NewMail } from "emails/gereric-email"; import Generic from "emails/gereric-email"; +import type { Invito_NewMail } from "emails/invito"; +import Invito from "emails/invito"; import OnboardingServizio, { type OnboardingServizio_NewMail, } from "emails/onboarding-servizio"; @@ -89,7 +91,8 @@ export type MailsTemplates = | VerificaEmail_NewMail | OnboardingServizio_NewMail | Generic_NewMail - | SchedaContatto_NewMail; + | SchedaContatto_NewMail + | Invito_NewMail; export const genMail = async ({ ...mail }: MailsTemplates) => { let mailComponent: JSX.Element | null = null; @@ -175,6 +178,15 @@ export const genMail = async ({ ...mail }: MailsTemplates) => { }); title = "Scheda Contatto Annuncio"; break; + case "invito": + mailComponent = Invito({ + ...mail.props, + }); + title = "Invito al sito"; + break; + default: + mailComponent = null; + break; } if (mailComponent === null) { throw new TRPCError({ diff --git a/apps/infoalloggi/src/server/services/password.service.ts b/apps/infoalloggi/src/server/services/password.service.ts index 0c81231..994442a 100644 --- a/apps/infoalloggi/src/server/services/password.service.ts +++ b/apps/infoalloggi/src/server/services/password.service.ts @@ -30,3 +30,8 @@ export async function comparePasswords({ export function generateSalt() { return crypto.randomBytes(16).toString("hex").normalize(); } + +export function generateTemporaryPassword() { + // Generate readable temporary password (e.g., "Abc12345") + return crypto.randomBytes(4).toString("hex"); +} diff --git a/apps/infoalloggi/src/server/services/user.service.ts b/apps/infoalloggi/src/server/services/user.service.ts index f68a709..ef7bc44 100644 --- a/apps/infoalloggi/src/server/services/user.service.ts +++ b/apps/infoalloggi/src/server/services/user.service.ts @@ -40,7 +40,15 @@ export const findUser_byId_MINI = async ({ try { return await db .selectFrom("users") - .select(["id", "username", "nome", "email", "isAdmin", "isBlocked"]) + .select([ + "id", + "username", + "nome", + "email", + "isAdmin", + "isBlocked", + "mustChangePassword", + ]) .where("id", "=", userId) .executeTakeFirst(); } catch (e) { @@ -119,6 +127,7 @@ export const getClientProfilo = async ({ "isAdmin", "isBlocked", "isAdminMade", + "mustChangePassword", ]) .where("id", "=", userId) .leftJoin("users_anagrafica", "users_anagrafica.userid", "users.id")