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:
parent
5b2a3964ac
commit
4d7802ccc6
42 changed files with 1004 additions and 623 deletions
9
apps/db/migrations/30_user_semplification.up.sql
Normal file
9
apps/db/migrations/30_user_semplification.up.sql
Normal 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;
|
||||
23
apps/db/migrations/31_magic_link.up.sql
Normal file
23
apps/db/migrations/31_magic_link.up.sql
Normal 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);
|
||||
45
apps/infoalloggi/emails/invito.tsx
Normal file
45
apps/infoalloggi/emails/invito.tsx
Normal 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;
|
||||
16
apps/infoalloggi/package-lock.json
generated
16
apps/infoalloggi/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 <div>Errore</div>;
|
||||
return (
|
||||
<div className="mb-5 flex flex-col space-y-5">
|
||||
|
|
@ -50,6 +59,17 @@ export const UserViewHeader = () => {
|
|||
<UserPen /> Intestazione
|
||||
</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 className="flex flex-row flex-wrap gap-2">
|
||||
<Link href={`/area-riservata/admin/user-view/edit-user/${data.id}`}>
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
|||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<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>
|
||||
<Link
|
||||
aria-label="Apri Onboarding Servizio"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
|
|
@ -26,18 +27,34 @@ export const ChangePasswordForm = () => {
|
|||
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<typeof ChangePasswordFormSchema>;
|
||||
|
|
@ -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 = () => {
|
|||
<form className="space-y-8 px-0.5" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="oldpassword"
|
||||
name="oldPsw"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex flex-wrap items-center gap-x-2">
|
||||
|
|
@ -96,11 +115,11 @@ export const ChangePasswordForm = () => {
|
|||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="newpassword"
|
||||
name="newPsw"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<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}
|
||||
</FormLabel>
|
||||
|
||||
|
|
@ -117,13 +136,13 @@ export const ChangePasswordForm = () => {
|
|||
<Input
|
||||
{...field}
|
||||
aria-describedby="password-strength"
|
||||
aria-invalid={form.getFieldState("newpassword").invalid}
|
||||
aria-invalid={form.getFieldState("newPsw").invalid}
|
||||
autoComplete="new-password"
|
||||
className="pe-9"
|
||||
id="newpassword"
|
||||
id="newPsw"
|
||||
onChange={async (e) => {
|
||||
field.onChange(e);
|
||||
await form.trigger("newpassword");
|
||||
await form.trigger("newPsw");
|
||||
}}
|
||||
placeholder="Password"
|
||||
type={isVisible ? "text" : "password"}
|
||||
|
|
@ -163,6 +182,27 @@ export const ChangePasswordForm = () => {
|
|||
</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>
|
||||
</form>
|
||||
</Form>
|
||||
|
|
|
|||
92
apps/infoalloggi/src/forms/FormMustChangePassword.tsx
Normal file
92
apps/infoalloggi/src/forms/FormMustChangePassword.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 = ({
|
|||
</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
|
||||
control={form.control}
|
||||
name="telefono"
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export const OverridePasswordForm = ({ id }: { id: UsersId }) => {
|
|||
},
|
||||
});
|
||||
|
||||
const { mutate } = api.auth.OverridePassword.useMutation({
|
||||
const { mutate } = api.auth.PasswordOverride.useMutation({
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -468,6 +468,7 @@ export type LangDict = {
|
|||
titolo: string;
|
||||
oldPw: string;
|
||||
newPw: string;
|
||||
confirmPw: string;
|
||||
ripetiPw: string;
|
||||
pwDiverse: string;
|
||||
email: string;
|
||||
|
|
|
|||
|
|
@ -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> | 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
30
apps/infoalloggi/src/middlewares/psw_change.ts
Normal file
30
apps/infoalloggi/src/middlewares/psw_change.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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<EditUserProps> = ({
|
|||
<TabsContent value="documenti">
|
||||
<DocumentiPersonali userData={userData} />
|
||||
</TabsContent>
|
||||
<TabsContent value="password">
|
||||
<TabsContent className="space-y-4" value="password">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<MustChangePasswordForm
|
||||
id={userData.id}
|
||||
mustChangePassword={userData.mustChangePassword}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<OverridePasswordForm id={userData.id} />
|
||||
|
|
|
|||
236
apps/infoalloggi/src/pages/auth/accetta-invito/[token].tsx
Normal file
236
apps/infoalloggi/src/pages/auth/accetta-invito/[token].tsx
Normal 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>;
|
||||
34
apps/infoalloggi/src/pages/auth/aggiorna-password.tsx
Normal file
34
apps/infoalloggi/src/pages/auth/aggiorna-password.tsx
Normal 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;
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
26
apps/infoalloggi/src/schemas/public/UserInvites.ts
Normal file
26
apps/infoalloggi/src/schemas/public/UserInvites.ts
Normal 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>;
|
||||
|
|
@ -34,13 +34,9 @@ export default interface UsersTable {
|
|||
|
||||
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>;
|
||||
|
||||
mustChangePassword: ColumnType<boolean, boolean | undefined, boolean>;
|
||||
}
|
||||
|
||||
export type Users = Selectable<UsersTable>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
101
apps/infoalloggi/src/server/api/routers/invite.ts
Normal file
101
apps/infoalloggi/src/server/api/routers/invite.ts
Normal 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 };
|
||||
}),
|
||||
});
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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<UsersUpdate>(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return await editAccountHandler({ ctx, input });
|
||||
checkCtxSession({ ctx });
|
||||
return await editAccountHandler({ ...input });
|
||||
}),
|
||||
editUserAnagrafica: protectedProcedure
|
||||
.input(
|
||||
|
|
|
|||
|
|
@ -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<typeof sessionSchema>;
|
||||
|
|
|
|||
|
|
@ -27,9 +27,14 @@ export async function signToken(
|
|||
export async function verifyToken(token: string): Promise<Session | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
101
apps/infoalloggi/src/server/controllers/invites.controller.ts
Normal file
101
apps/infoalloggi/src/server/controllers/invites.controller.ts
Normal 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}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue