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": "^1.9.4",
|
||||||
"leaflet-defaulticon-compatibility": "^0.1.2",
|
"leaflet-defaulticon-compatibility": "^0.1.2",
|
||||||
"lucide-react": "^0.536.0",
|
"lucide-react": "^0.536.0",
|
||||||
"next": "15.4.8",
|
"next": "^15.4.10",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nextjs-progressbar": "^0.0.16",
|
"nextjs-progressbar": "^0.0.16",
|
||||||
"nodemailer": "^7.0.5",
|
"nodemailer": "^7.0.5",
|
||||||
|
|
@ -2785,9 +2785,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "15.4.8",
|
"version": "15.4.10",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.4.8.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.4.10.tgz",
|
||||||
"integrity": "sha512-LydLa2MDI1NMrOFSkO54mTc8iIHSttj6R6dthITky9ylXV2gCGi0bHQjVCtLGRshdRPjyh2kXbxJukDtBWQZtQ==",
|
"integrity": "sha512-knhmoJ0Vv7VRf6pZEPSnciUG1S4bIhWx+qTYBW/AjxEtlzsiNORPk8sFDCEvqLfmKuey56UB9FL1UdHEV3uBrg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-arm64": {
|
"node_modules/@next/swc-darwin-arm64": {
|
||||||
|
|
@ -14138,13 +14138,13 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "15.4.8",
|
"version": "15.4.10",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-15.4.8.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-15.4.10.tgz",
|
||||||
"integrity": "sha512-jwOXTz/bo0Pvlf20FSb6VXVeWRssA2vbvq9SdrOPEg9x8E1B27C2rQtvriAn600o9hH61kjrVRexEffv3JybuA==",
|
"integrity": "sha512-itVlc79QjpKMFMRhP+kbGKaSG/gZM6RCvwhEbwmCNF06CdDiNaoHcbeg0PqkEa2GOcn8KJ0nnc7+yL7EjoYLHQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "15.4.8",
|
"@next/env": "15.4.10",
|
||||||
"@swc/helpers": "0.5.15",
|
"@swc/helpers": "0.5.15",
|
||||||
"caniuse-lite": "^1.0.30001579",
|
"caniuse-lite": "^1.0.30001579",
|
||||||
"postcss": "8.4.31",
|
"postcss": "8.4.31",
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"leaflet-defaulticon-compatibility": "^0.1.2",
|
"leaflet-defaulticon-compatibility": "^0.1.2",
|
||||||
"lucide-react": "^0.536.0",
|
"lucide-react": "^0.536.0",
|
||||||
"next": "15.4.8",
|
"next": "15.4.10",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nextjs-progressbar": "^0.0.16",
|
"nextjs-progressbar": "^0.0.16",
|
||||||
"nodemailer": "^7.0.5",
|
"nodemailer": "^7.0.5",
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { useRouter } from "next/router";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useUserViewContext } from "~/lib/userViewContext";
|
import { useUserViewContext } from "~/lib/userViewContext";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
import { Confirm } from "../confirm";
|
||||||
import { WhatsAppIcon2 } from "../svgs";
|
import { WhatsAppIcon2 } from "../svgs";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { Separator } from "../ui/separator";
|
import { Separator } from "../ui/separator";
|
||||||
|
|
@ -31,6 +32,14 @@ export const UserViewHeader = () => {
|
||||||
router.reload();
|
router.reload();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const { mutateAsync: sendInvite } = api.invite.sendInvite.useMutation({
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success("Email inviata con successo");
|
||||||
|
},
|
||||||
|
});
|
||||||
if (!data) return <div>Errore</div>;
|
if (!data) return <div>Errore</div>;
|
||||||
return (
|
return (
|
||||||
<div className="mb-5 flex flex-col space-y-5">
|
<div className="mb-5 flex flex-col space-y-5">
|
||||||
|
|
@ -50,6 +59,17 @@ export const UserViewHeader = () => {
|
||||||
<UserPen /> Intestazione
|
<UserPen /> Intestazione
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<Confirm
|
||||||
|
description={`Verrà inviata un'email di invito al sito all'indirizzo ${data.email}`}
|
||||||
|
onConfirm={async () => {
|
||||||
|
await sendInvite({ id: data.id });
|
||||||
|
}}
|
||||||
|
title="Vuoi mandare l'email di invito al sito?"
|
||||||
|
>
|
||||||
|
<Button size="sm" variant="info">
|
||||||
|
<Mail /> Invita al sito
|
||||||
|
</Button>
|
||||||
|
</Confirm>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row flex-wrap gap-2">
|
<div className="flex flex-row flex-wrap gap-2">
|
||||||
<Link href={`/area-riservata/admin/user-view/edit-user/${data.id}`}>
|
<Link href={`/area-riservata/admin/user-view/edit-user/${data.id}`}>
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,11 @@ import {
|
||||||
Copy,
|
Copy,
|
||||||
EllipsisVertical,
|
EllipsisVertical,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Mail,
|
|
||||||
Trash2,
|
Trash2,
|
||||||
Wrench,
|
Wrench,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { Confirm } from "~/components/confirm";
|
|
||||||
import { LoadingPage } from "~/components/loading";
|
import { LoadingPage } from "~/components/loading";
|
||||||
import { AddAnnuncio } from "~/components/servizio/servizio_actions";
|
import { AddAnnuncio } from "~/components/servizio/servizio_actions";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
|
|
@ -65,15 +63,6 @@ export const TabServizio = ({
|
||||||
await utils.servizio.getUserServizi.invalidate();
|
await utils.servizio.getUserServizi.invalidate();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { mutate: sendEmail } = api.servizio.sendServizioEmail.useMutation({
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error(error.message);
|
|
||||||
},
|
|
||||||
onSuccess: async () => {
|
|
||||||
toast.success("Email onboarding inviata con successo");
|
|
||||||
await utils.servizio.getUserServizi.invalidate();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutate: update } = api.servizio.updateServizio.useMutation({
|
const { mutate: update } = api.servizio.updateServizio.useMutation({
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
|
|
@ -154,31 +143,6 @@ export const TabServizio = ({
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Confirm
|
|
||||||
description="Sei sicuro di voler inviare l'email?"
|
|
||||||
onConfirm={() => {
|
|
||||||
sendEmail({
|
|
||||||
servizioId: servizio.servizio_id,
|
|
||||||
userId: userId,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
title="Invia email"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
aria-label="Invia Email Servizio"
|
|
||||||
className="flex w-full justify-start gap-3 px-2 font-normal"
|
|
||||||
//disabled={onboardDone}
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<Mail className="size-5 stroke-foreground" />
|
|
||||||
{servizio.isSent
|
|
||||||
? "Re-Invia all'utente"
|
|
||||||
: "Invia all'utente"}
|
|
||||||
</Button>
|
|
||||||
</Confirm>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link
|
<Link
|
||||||
aria-label="Apri Onboarding Servizio"
|
aria-label="Apri Onboarding Servizio"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Eye, EyeOff } from "lucide-react";
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
import {
|
import {
|
||||||
|
|
@ -26,18 +27,34 @@ export const ChangePasswordForm = () => {
|
||||||
strengthScore,
|
strengthScore,
|
||||||
toggleVisibility,
|
toggleVisibility,
|
||||||
} = useStrongPassword();
|
} = useStrongPassword();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const ChangePasswordFormSchema = z
|
const ChangePasswordFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
newpassword: z.string(),
|
oldPsw: z.string().min(1, "Inserisci la password corrente"),
|
||||||
oldpassword: z.string(),
|
newPsw: z.string(),
|
||||||
|
confirmPsw: z.string().min(1, "Ripeti la nuova password"),
|
||||||
})
|
})
|
||||||
.superRefine(({ newpassword }, refinementContext) => {
|
.superRefine(({ oldPsw, newPsw, confirmPsw }, refinementContext) => {
|
||||||
passwordRefine({
|
passwordRefine({
|
||||||
password: newpassword,
|
password: newPsw,
|
||||||
path: "newpassword",
|
path: "newPsw",
|
||||||
refinementContext,
|
refinementContext,
|
||||||
});
|
});
|
||||||
|
if (oldPsw === newPsw) {
|
||||||
|
refinementContext.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "La nuova password deve essere diversa da quella corrente",
|
||||||
|
path: ["newPsw"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (newPsw !== confirmPsw) {
|
||||||
|
refinementContext.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "Le password non coincidono",
|
||||||
|
path: ["confirmPsw"],
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
type ChangePasswordFormValues = z.infer<typeof ChangePasswordFormSchema>;
|
type ChangePasswordFormValues = z.infer<typeof ChangePasswordFormSchema>;
|
||||||
|
|
@ -48,8 +65,9 @@ export const ChangePasswordForm = () => {
|
||||||
|
|
||||||
const form = useZodForm(ChangePasswordFormSchema, {
|
const form = useZodForm(ChangePasswordFormSchema, {
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
newpassword: "",
|
newPsw: "",
|
||||||
oldpassword: "",
|
oldPsw: "",
|
||||||
|
confirmPsw: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -57,16 +75,17 @@ export const ChangePasswordForm = () => {
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(error.message);
|
toast.error(error.message);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: async () => {
|
||||||
toast.success(t.pwReset.pwAggiornata);
|
toast.success(t.pwReset.pwAggiornata);
|
||||||
form.reset();
|
form.reset();
|
||||||
|
await router.push("/area-riservata/dashboard");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function onSubmit(fields: ChangePasswordFormValues) {
|
function onSubmit(fields: ChangePasswordFormValues) {
|
||||||
mutate({
|
mutate({
|
||||||
newpassword: fields.newpassword,
|
newpassword: fields.newPsw,
|
||||||
oldpassword: fields.oldpassword,
|
oldpassword: fields.oldPsw,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,7 +94,7 @@ export const ChangePasswordForm = () => {
|
||||||
<form className="space-y-8 px-0.5" onSubmit={form.handleSubmit(onSubmit)}>
|
<form className="space-y-8 px-0.5" onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="oldpassword"
|
name="oldPsw"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<div className="flex flex-wrap items-center gap-x-2">
|
<div className="flex flex-wrap items-center gap-x-2">
|
||||||
|
|
@ -96,11 +115,11 @@ export const ChangePasswordForm = () => {
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="newpassword"
|
name="newPsw"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<div className="flex flex-col gap-x-2">
|
<div className="flex flex-col gap-x-2">
|
||||||
<FormLabel className="text-neutral-600" htmlFor="newpassword">
|
<FormLabel className="text-neutral-600" htmlFor="newPsw">
|
||||||
{t.pwReset.newPw}
|
{t.pwReset.newPw}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
|
|
@ -117,13 +136,13 @@ export const ChangePasswordForm = () => {
|
||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
aria-describedby="password-strength"
|
aria-describedby="password-strength"
|
||||||
aria-invalid={form.getFieldState("newpassword").invalid}
|
aria-invalid={form.getFieldState("newPsw").invalid}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
className="pe-9"
|
className="pe-9"
|
||||||
id="newpassword"
|
id="newPsw"
|
||||||
onChange={async (e) => {
|
onChange={async (e) => {
|
||||||
field.onChange(e);
|
field.onChange(e);
|
||||||
await form.trigger("newpassword");
|
await form.trigger("newPsw");
|
||||||
}}
|
}}
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
type={isVisible ? "text" : "password"}
|
type={isVisible ? "text" : "password"}
|
||||||
|
|
@ -163,6 +182,27 @@ export const ChangePasswordForm = () => {
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="confirmPsw"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2">
|
||||||
|
<FormLabel htmlFor="confirmpw">{t.pwReset.confirmPw}</FormLabel>
|
||||||
|
<FormMessage />
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder=""
|
||||||
|
{...field}
|
||||||
|
autoComplete="off"
|
||||||
|
id="confirmpw"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<Button type="submit">{t.pwReset.aggiorna}</Button>
|
<Button type="submit">{t.pwReset.aggiorna}</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
|
||||||
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 Input from "~/components/custom_ui/input";
|
||||||
import { PhoneInput } from "~/components/phone-input";
|
import { PhoneInput } from "~/components/phone-input";
|
||||||
import { Button, buttonVariants } from "~/components/ui/button";
|
import { Button, buttonVariants } from "~/components/ui/button";
|
||||||
|
import { Switch } from "~/components/ui/switch";
|
||||||
import { useStrongPassword } from "~/hooks/useStrongPassword";
|
import { useStrongPassword } from "~/hooks/useStrongPassword";
|
||||||
import { generatePassword } from "~/lib/basicPwGenerator";
|
import { generatePassword } from "~/lib/basicPwGenerator";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
|
@ -40,6 +41,7 @@ export const FormNewUser = ({
|
||||||
nome: z.string().nonempty("Inserisci un nome valido"),
|
nome: z.string().nonempty("Inserisci un nome valido"),
|
||||||
password: z.string(),
|
password: z.string(),
|
||||||
telefono: z.string().nonempty("Inserisci un numero di telefono"),
|
telefono: z.string().nonempty("Inserisci un numero di telefono"),
|
||||||
|
mustChangePassword: z.boolean(),
|
||||||
})
|
})
|
||||||
.superRefine(({ password }, refinementContext) => {
|
.superRefine(({ password }, refinementContext) => {
|
||||||
passwordRefine({
|
passwordRefine({
|
||||||
|
|
@ -57,6 +59,7 @@ export const FormNewUser = ({
|
||||||
nome: "",
|
nome: "",
|
||||||
password: "",
|
password: "",
|
||||||
telefono: "",
|
telefono: "",
|
||||||
|
mustChangePassword: true,
|
||||||
};
|
};
|
||||||
const { locale, t } = useTranslation();
|
const { locale, t } = useTranslation();
|
||||||
|
|
||||||
|
|
@ -86,6 +89,7 @@ export const FormNewUser = ({
|
||||||
nome: fields.nome,
|
nome: fields.nome,
|
||||||
password: fields.password,
|
password: fields.password,
|
||||||
telefono: fields.telefono.replace(/\s/g, ""),
|
telefono: fields.telefono.replace(/\s/g, ""),
|
||||||
|
mustChangePassword: fields.mustChangePassword,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -222,6 +226,28 @@ export const FormNewUser = ({
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="mustChangePassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2">
|
||||||
|
<FormLabel htmlFor="mustChangePassword">
|
||||||
|
Forza cambio password al prossimo accesso
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
className="data-[state=checked]:bg-neutral-700"
|
||||||
|
id="mustChangePassword"
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="telefono"
|
name="telefono"
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export const OverridePasswordForm = ({ id }: { id: UsersId }) => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutate } = api.auth.OverridePassword.useMutation({
|
const { mutate } = api.auth.PasswordOverride.useMutation({
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(error.message);
|
toast.error(error.message);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import { Button } from "~/components/ui/button";
|
||||||
import { Switch } from "~/components/ui/switch";
|
import { Switch } from "~/components/ui/switch";
|
||||||
import { useZodForm } from "~/lib/zodForm";
|
import { useZodForm } from "~/lib/zodForm";
|
||||||
import { useTranslation } from "~/providers/I18nProvider";
|
import { useTranslation } from "~/providers/I18nProvider";
|
||||||
import type { Users, UsersId } from "~/schemas/public/Users";
|
import type { Users } from "~/schemas/public/Users";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
type ProfileFromAccountProps = Pick<
|
type ProfileFromAccountProps = Pick<
|
||||||
|
|
@ -73,25 +73,15 @@ export const ProfileFormAccount = ({
|
||||||
});
|
});
|
||||||
|
|
||||||
function onSubmit(fields: ProfileFormValues) {
|
function onSubmit(fields: ProfileFormValues) {
|
||||||
const data: {
|
mutate({
|
||||||
id: UsersId;
|
id,
|
||||||
email: string;
|
data: {
|
||||||
isAdmin?: boolean;
|
...fields,
|
||||||
nome: string;
|
telefono: fields.telefono?.replace(/\s/g, ""),
|
||||||
cognome: string;
|
username: `${fields.nome?.trim()} ${fields.cognome?.trim()}`,
|
||||||
telefono: string;
|
isAdmin: fields.isAdmin || false,
|
||||||
} = {
|
},
|
||||||
cognome: fields.cognome,
|
});
|
||||||
email: fields.email,
|
|
||||||
id: id,
|
|
||||||
nome: fields.nome,
|
|
||||||
telefono: fields.telefono.replace(/\s/g, ""),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isAdmin !== undefined) {
|
|
||||||
data.isAdmin = fields.isAdmin;
|
|
||||||
}
|
|
||||||
mutate(data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -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",
|
email: "Email",
|
||||||
get_link: "Get the reset link",
|
get_link: "Get the reset link",
|
||||||
newPw: "New password",
|
newPw: "New password",
|
||||||
|
confirmPw: "Repeat new password",
|
||||||
notvalid_cta: "Request a new link",
|
notvalid_cta: "Request a new link",
|
||||||
notvalid_desc:
|
notvalid_desc:
|
||||||
"The link you clicked is not valid or has expired, request a new one",
|
"The link you clicked is not valid or has expired, request a new one",
|
||||||
|
|
|
||||||
|
|
@ -1184,6 +1184,7 @@ export const it: LangDict = {
|
||||||
email: "Email",
|
email: "Email",
|
||||||
get_link: "Ricevi link per il reset",
|
get_link: "Ricevi link per il reset",
|
||||||
newPw: "Nuova password",
|
newPw: "Nuova password",
|
||||||
|
confirmPw: "Ripeti nuova password",
|
||||||
notvalid_cta: "Richiedi un nuovo link",
|
notvalid_cta: "Richiedi un nuovo link",
|
||||||
notvalid_desc: "Il link per il reset password non è valido o è scaduto",
|
notvalid_desc: "Il link per il reset password non è valido o è scaduto",
|
||||||
notvalid_title: "Link non valido",
|
notvalid_title: "Link non valido",
|
||||||
|
|
|
||||||
|
|
@ -468,6 +468,7 @@ export type LangDict = {
|
||||||
titolo: string;
|
titolo: string;
|
||||||
oldPw: string;
|
oldPw: string;
|
||||||
newPw: string;
|
newPw: string;
|
||||||
|
confirmPw: string;
|
||||||
ripetiPw: string;
|
ripetiPw: string;
|
||||||
pwDiverse: string;
|
pwDiverse: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { authMiddleware } from "~/middlewares/auth_middleware";
|
import { authMiddleware } from "~/middlewares/auth";
|
||||||
import { apisMiddleware } from "./middlewares/api_middleware";
|
import { apisMiddleware } from "./middlewares/api";
|
||||||
|
import { pswChangeMiddleware } from "./middlewares/psw_change";
|
||||||
|
|
||||||
export async function middleware(request: NextRequest) {
|
export async function middleware(request: NextRequest) {
|
||||||
//console.log("Middleware triggered for:", request.nextUrl.pathname);
|
//console.log("Middleware triggered for:", request.nextUrl.pathname);
|
||||||
const { pathname } = request.nextUrl;
|
|
||||||
if (pathname.startsWith("/storage-api/")) {
|
|
||||||
return await apisMiddleware(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (await authMiddleware(request)) ?? NextResponse.next();
|
return await chainMiddlewares(request, [
|
||||||
|
apisMiddleware,
|
||||||
|
pswChangeMiddleware,
|
||||||
|
authMiddleware,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
|
|
@ -28,3 +29,27 @@ export const config = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Type for middleware functions
|
||||||
|
export type MiddlewareFn = (
|
||||||
|
request: NextRequest,
|
||||||
|
) => Promise<NextResponse | null | undefined> | NextResponse | null | undefined;
|
||||||
|
|
||||||
|
// Helper to chain middlewares in order
|
||||||
|
async function chainMiddlewares(
|
||||||
|
request: NextRequest,
|
||||||
|
middlewares: MiddlewareFn[],
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
for (const middleware of middlewares) {
|
||||||
|
const result = await middleware(request);
|
||||||
|
|
||||||
|
// If middleware returned a response, short-circuit
|
||||||
|
if (result) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
// Otherwise, continue to next middleware
|
||||||
|
}
|
||||||
|
|
||||||
|
// All middlewares passed, continue to the app
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
|
import type { MiddlewareFn } from "~/middleware";
|
||||||
import { TOKEN_CONFIG } from "~/server/auth/configs";
|
import { TOKEN_CONFIG } from "~/server/auth/configs";
|
||||||
import { verifyToken } from "~/server/auth/jwt";
|
import { verifyToken } from "~/server/auth/jwt";
|
||||||
|
|
||||||
const STALE_KEY = "stale-resource";
|
const STALE_KEY = "stale-resource";
|
||||||
export const apisMiddleware = async (req: NextRequest) => {
|
export const apisMiddleware: MiddlewareFn = async (req: NextRequest) => {
|
||||||
const { pathname, searchParams } = req.nextUrl;
|
const { pathname, searchParams } = req.nextUrl;
|
||||||
|
|
||||||
// Only handle storage API routes
|
|
||||||
if (!pathname.startsWith("/storage-api/")) {
|
if (!pathname.startsWith("/storage-api/")) {
|
||||||
return null; // Pass to next middleware
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams(searchParams);
|
const params = new URLSearchParams(searchParams);
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { add } from "date-fns";
|
import { add } from "date-fns";
|
||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import type { MiddlewareFn } from "~/middleware";
|
||||||
import { TOKEN_CONFIG } from "~/server/auth/configs";
|
import { TOKEN_CONFIG } from "~/server/auth/configs";
|
||||||
|
|
||||||
import { signToken, verifyToken } from "~/server/auth/jwt";
|
import { signToken, verifyToken } from "~/server/auth/jwt";
|
||||||
|
|
||||||
export const authMiddleware = async (req: NextRequest) => {
|
export const authMiddleware: MiddlewareFn = async (req: NextRequest) => {
|
||||||
const path = req.nextUrl.pathname;
|
const path = req.nextUrl.pathname;
|
||||||
|
|
||||||
const refreshToken = req.cookies.get(
|
const refreshToken = req.cookies.get(
|
||||||
|
|
@ -51,7 +51,7 @@ export const authMiddleware = async (req: NextRequest) => {
|
||||||
new URL("/area-riservata/dashboard", req.nextUrl),
|
new URL("/area-riservata/dashboard", req.nextUrl),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return NextResponse.next();
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (path.startsWith("/login")) {
|
if (path.startsWith("/login")) {
|
||||||
|
|
@ -92,7 +92,7 @@ export const authMiddleware = async (req: NextRequest) => {
|
||||||
}
|
}
|
||||||
if (path.startsWith("/auth/new-password-reset")) {
|
if (path.startsWith("/auth/new-password-reset")) {
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
return NextResponse.next();
|
return;
|
||||||
}
|
}
|
||||||
return NextResponse.redirect(new URL("/", req.nextUrl));
|
return NextResponse.redirect(new URL("/", req.nextUrl));
|
||||||
}
|
}
|
||||||
|
|
@ -102,9 +102,19 @@ export const authMiddleware = async (req: NextRequest) => {
|
||||||
if (refreshToken) {
|
if (refreshToken) {
|
||||||
return await refreshAuthToken(req, refreshToken, false);
|
return await refreshAuthToken(req, refreshToken, false);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
const payload = await verifyToken(accessToken);
|
||||||
|
if (!payload) {
|
||||||
|
if (refreshToken) {
|
||||||
|
return await refreshAuthToken(req, refreshToken, false);
|
||||||
|
}
|
||||||
|
const response = NextResponse.next();
|
||||||
|
response.cookies.delete(TOKEN_CONFIG.ACCESS_TOKEN_COOKIE_NAME);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.next();
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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 { Card, CardContent } from "~/components/ui/card";
|
||||||
import { Separator } from "~/components/ui/separator";
|
import { Separator } from "~/components/ui/separator";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||||
|
import { MustChangePasswordForm } from "~/forms/FormMustChangePassword";
|
||||||
import { OverridePasswordForm } from "~/forms/FormOverridePassword";
|
import { OverridePasswordForm } from "~/forms/FormOverridePassword";
|
||||||
import { ProfileFormAccount } from "~/forms/FormProfilo_Account";
|
import { ProfileFormAccount } from "~/forms/FormProfilo_Account";
|
||||||
import { ProfileFormAnagrafica } from "~/forms/FormProfilo_Anagrafica";
|
import { ProfileFormAnagrafica } from "~/forms/FormProfilo_Anagrafica";
|
||||||
|
|
@ -211,7 +212,15 @@ const EditUser: NextPageWithLayout<EditUserProps> = ({
|
||||||
<TabsContent value="documenti">
|
<TabsContent value="documenti">
|
||||||
<DocumentiPersonali userData={userData} />
|
<DocumentiPersonali userData={userData} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="password">
|
<TabsContent className="space-y-4" value="password">
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<MustChangePasswordForm
|
||||||
|
id={userData.id}
|
||||||
|
mustChangePassword={userData.mustChangePassword}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<OverridePasswordForm id={userData.id} />
|
<OverridePasswordForm id={userData.id} />
|
||||||
|
|
|
||||||
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 TestiEStringheTable } from './TestiEStringhe';
|
||||||
import type { default as UsersStorageTable } from './UsersStorage';
|
import type { default as UsersStorageTable } from './UsersStorage';
|
||||||
import type { default as ServizioAnnunciTable } from './ServizioAnnunci';
|
import type { default as ServizioAnnunciTable } from './ServizioAnnunci';
|
||||||
|
import type { default as UserInvitesTable } from './UserInvites';
|
||||||
import type { default as EmailsTable } from './Emails';
|
import type { default as EmailsTable } from './Emails';
|
||||||
import type { default as PaymentsTable } from './Payments';
|
import type { default as PaymentsTable } from './Payments';
|
||||||
import type { default as PotenzialiGroupsTable } from './PotenzialiGroups';
|
import type { default as PotenzialiGroupsTable } from './PotenzialiGroups';
|
||||||
|
|
@ -47,6 +48,8 @@ export default interface PublicSchema {
|
||||||
|
|
||||||
servizio_annunci: ServizioAnnunciTable;
|
servizio_annunci: ServizioAnnunciTable;
|
||||||
|
|
||||||
|
user_invites: UserInvitesTable;
|
||||||
|
|
||||||
emails: EmailsTable;
|
emails: EmailsTable;
|
||||||
|
|
||||||
payments: PaymentsTable;
|
payments: PaymentsTable;
|
||||||
|
|
|
||||||
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>;
|
isAdminMade: ColumnType<boolean | null, boolean | null, boolean | null>;
|
||||||
|
|
||||||
isVerified: ColumnType<boolean, boolean | undefined, boolean>;
|
|
||||||
|
|
||||||
verificationToken: ColumnType<string | null, string | null, string | null>;
|
|
||||||
|
|
||||||
verificationTokenExpires: ColumnType<Date | null, Date | string | null, Date | string | null>;
|
|
||||||
|
|
||||||
salt: ColumnType<string, string, string>;
|
salt: ColumnType<string, string, string>;
|
||||||
|
|
||||||
|
mustChangePassword: ColumnType<boolean, boolean | undefined, boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Users = Selectable<UsersTable>;
|
export type Users = Selectable<UsersTable>;
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import { bannersRouter } from "./routers/banners";
|
||||||
import { catastoRouter } from "./routers/catasto";
|
import { catastoRouter } from "./routers/catasto";
|
||||||
import { etichetteRouter } from "./routers/etichette";
|
import { etichetteRouter } from "./routers/etichette";
|
||||||
import { flagsRouter } from "./routers/flags";
|
import { flagsRouter } from "./routers/flags";
|
||||||
|
import { inviteRouter } from "./routers/invite";
|
||||||
import { potenzialiRouter } from "./routers/potenziali";
|
import { potenzialiRouter } from "./routers/potenziali";
|
||||||
import { revalidationRouter } from "./routers/revalidation";
|
import { revalidationRouter } from "./routers/revalidation";
|
||||||
import { stringsRouter } from "./routers/strings";
|
import { stringsRouter } from "./routers/strings";
|
||||||
|
|
@ -57,5 +58,6 @@ export const appRouter = createTRPCRouter({
|
||||||
strings: stringsRouter,
|
strings: stringsRouter,
|
||||||
revalidation: revalidationRouter,
|
revalidation: revalidationRouter,
|
||||||
potenziali: potenzialiRouter,
|
potenziali: potenzialiRouter,
|
||||||
|
invite: inviteRouter,
|
||||||
});
|
});
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ export const authRouter = createTRPCRouter({
|
||||||
nome: z.string().nonempty("Inserisci un nome valido"),
|
nome: z.string().nonempty("Inserisci un nome valido"),
|
||||||
password: zStrongPassword,
|
password: zStrongPassword,
|
||||||
telefono: z.string().nonempty("Inserisci un numero di telefono"),
|
telefono: z.string().nonempty("Inserisci un numero di telefono"),
|
||||||
|
mustChangePassword: z.boolean(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
|
|
@ -56,6 +57,7 @@ export const authRouter = createTRPCRouter({
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
return await passwordChange({
|
return await passwordChange({
|
||||||
userId: ctx.session.id,
|
userId: ctx.session.id,
|
||||||
|
ctx,
|
||||||
...input,
|
...input,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
@ -94,7 +96,7 @@ export const authRouter = createTRPCRouter({
|
||||||
return "logout success";
|
return "logout success";
|
||||||
}),
|
}),
|
||||||
|
|
||||||
OverridePassword: adminProcedure
|
PasswordOverride: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
id: zUserId,
|
id: zUserId,
|
||||||
|
|
@ -102,7 +104,7 @@ export const authRouter = createTRPCRouter({
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
return await passwordOverrideHandler({ input });
|
return await passwordOverrideHandler({ ...input });
|
||||||
}),
|
}),
|
||||||
pwResetGenTokenMail: publicProcedure
|
pwResetGenTokenMail: publicProcedure
|
||||||
.input(z.object({ email: z.string() }))
|
.input(z.object({ email: z.string() }))
|
||||||
|
|
@ -140,21 +142,4 @@ export const authRouter = createTRPCRouter({
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
return await swapAuth({ ctx, userId: input.id });
|
return await swapAuth({ ctx, userId: input.id });
|
||||||
}),
|
}),
|
||||||
UpdatePasswordAndLogin: publicProcedure
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
id: zUserId,
|
|
||||||
password: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.mutation(async ({ input, ctx }) => {
|
|
||||||
await passwordOverrideHandler({ input });
|
|
||||||
if (!ctx.session) {
|
|
||||||
await swapAuth({
|
|
||||||
ctx: ctx,
|
|
||||||
userId: input.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
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,
|
removePagamento,
|
||||||
SbloccaContatti,
|
SbloccaContatti,
|
||||||
SendContactEmail,
|
SendContactEmail,
|
||||||
sendServizioEmail,
|
|
||||||
setupSaldoConsulenzaServizio,
|
setupSaldoConsulenzaServizio,
|
||||||
setupSaldoServizio,
|
setupSaldoServizio,
|
||||||
updatePagamento,
|
updatePagamento,
|
||||||
|
|
@ -314,19 +313,7 @@ export const servizioRouter = createTRPCRouter({
|
||||||
...input,
|
...input,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
sendServizioEmail: protectedProcedure
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
servizioId: zServizioId,
|
|
||||||
userId: zUserId,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.mutation(async ({ input }) => {
|
|
||||||
return await sendServizioEmail({
|
|
||||||
servizioId: input.servizioId,
|
|
||||||
userId: input.userId,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
setupSaldoConsulenzaServizio: protectedProcedure
|
setupSaldoConsulenzaServizio: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
import type { UsersUpdate } from "~/schemas/public/Users";
|
||||||
import type { UsersAnagraficaUpdate } from "~/schemas/public/UsersAnagrafica";
|
import type { UsersAnagraficaUpdate } from "~/schemas/public/UsersAnagrafica";
|
||||||
import {
|
import {
|
||||||
adminProcedure,
|
adminProcedure,
|
||||||
|
|
@ -16,6 +17,7 @@ import {
|
||||||
} from "~/server/controllers/user.controller";
|
} from "~/server/controllers/user.controller";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { getClientProfilo } from "~/server/services/user.service";
|
import { getClientProfilo } from "~/server/services/user.service";
|
||||||
|
import { checkCtxSession } from "~/server/utils/ctxChecker";
|
||||||
import { zUserId } from "~/server/utils/zod_types";
|
import { zUserId } from "~/server/utils/zod_types";
|
||||||
|
|
||||||
export const usersRouter = createTRPCRouter({
|
export const usersRouter = createTRPCRouter({
|
||||||
|
|
@ -40,16 +42,13 @@ export const usersRouter = createTRPCRouter({
|
||||||
editUser: protectedProcedure
|
editUser: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
cognome: z.string(),
|
|
||||||
email: z.email(),
|
|
||||||
id: zUserId,
|
id: zUserId,
|
||||||
isAdmin: z.boolean().optional(),
|
data: z.custom<UsersUpdate>(),
|
||||||
nome: z.string(),
|
|
||||||
telefono: z.string(),
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
return await editAccountHandler({ ctx, input });
|
checkCtxSession({ ctx });
|
||||||
|
return await editAccountHandler({ ...input });
|
||||||
}),
|
}),
|
||||||
editUserAnagrafica: protectedProcedure
|
editUserAnagrafica: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ export const sessionSchema = z.object({
|
||||||
isBlocked: z.boolean(),
|
isBlocked: z.boolean(),
|
||||||
nome: z.string(),
|
nome: z.string(),
|
||||||
username: z.string(),
|
username: z.string(),
|
||||||
|
mustChangePassword: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Session = z.infer<typeof sessionSchema>;
|
export type Session = z.infer<typeof sessionSchema>;
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,14 @@ export async function signToken(
|
||||||
export async function verifyToken(token: string): Promise<Session | null> {
|
export async function verifyToken(token: string): Promise<Session | null> {
|
||||||
try {
|
try {
|
||||||
const { payload } = await jwtVerify(token, secret);
|
const { payload } = await jwtVerify(token, secret);
|
||||||
return sessionSchema.parse(payload.payload);
|
const parse = sessionSchema.safeParse(payload.payload);
|
||||||
|
if (!parse.success) {
|
||||||
|
//console.error("JWT payload invalid:", parse.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parse.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("JWT verification error:", (e as Error).message);
|
console.error("JWT verification Internal Error:", (e as Error).message);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,14 @@ export const createUserAdmin = async ({
|
||||||
nome,
|
nome,
|
||||||
cognome,
|
cognome,
|
||||||
telefono,
|
telefono,
|
||||||
|
mustChangePassword,
|
||||||
}: {
|
}: {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
nome: string;
|
nome: string;
|
||||||
cognome: string;
|
cognome: string;
|
||||||
telefono: string;
|
telefono: string;
|
||||||
|
mustChangePassword: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const banned = await isBanned({ data: { email }, db });
|
const banned = await isBanned({ data: { email }, db });
|
||||||
if (banned) {
|
if (banned) {
|
||||||
|
|
@ -49,9 +51,9 @@ export const createUserAdmin = async ({
|
||||||
cognome: cognome.trim(),
|
cognome: cognome.trim(),
|
||||||
email,
|
email,
|
||||||
isAdminMade: true,
|
isAdminMade: true,
|
||||||
isVerified: true,
|
|
||||||
nome: nome.trim(),
|
nome: nome.trim(),
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
|
mustChangePassword,
|
||||||
salt,
|
salt,
|
||||||
telefono,
|
telefono,
|
||||||
username: `${nome.trim()} ${cognome.trim()}`,
|
username: `${nome.trim()} ${cognome.trim()}`,
|
||||||
|
|
@ -124,6 +126,7 @@ export const logIn = async ({
|
||||||
isBlocked: user.isBlocked,
|
isBlocked: user.isBlocked,
|
||||||
nome: user.nome,
|
nome: user.nome,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
mustChangePassword: user.mustChangePassword,
|
||||||
};
|
};
|
||||||
|
|
||||||
const accessToken = await signToken(
|
const accessToken = await signToken(
|
||||||
|
|
@ -241,11 +244,20 @@ export const passwordChange = async ({
|
||||||
userId,
|
userId,
|
||||||
oldpassword,
|
oldpassword,
|
||||||
newpassword,
|
newpassword,
|
||||||
|
ctx,
|
||||||
}: {
|
}: {
|
||||||
userId: UsersId;
|
userId: UsersId;
|
||||||
oldpassword: string;
|
oldpassword: string;
|
||||||
newpassword: string;
|
newpassword: string;
|
||||||
|
ctx: Context;
|
||||||
}) => {
|
}) => {
|
||||||
|
const { req, res } = ctx;
|
||||||
|
if (!req || !res) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Request/Response context mancante",
|
||||||
|
});
|
||||||
|
}
|
||||||
const dbUser = await findUser_byId({ id: userId });
|
const dbUser = await findUser_byId({ id: userId });
|
||||||
if (!dbUser) {
|
if (!dbUser) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
|
|
@ -280,10 +292,51 @@ export const passwordChange = async ({
|
||||||
.set({
|
.set({
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
salt,
|
salt,
|
||||||
|
mustChangePassword: false,
|
||||||
})
|
})
|
||||||
.where("id", "=", userId)
|
.where("id", "=", userId)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
|
// Refresh tokens with updated mustChangePassword value
|
||||||
|
const payload = {
|
||||||
|
email: dbUser.email,
|
||||||
|
id: dbUser.id,
|
||||||
|
isAdmin: dbUser.isAdmin,
|
||||||
|
isBlocked: dbUser.isBlocked,
|
||||||
|
nome: dbUser.nome,
|
||||||
|
username: dbUser.username,
|
||||||
|
mustChangePassword: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const accessToken = await signToken(
|
||||||
|
payload,
|
||||||
|
TOKEN_CONFIG.ACCESS_TOKEN_EXPIRY,
|
||||||
|
"accessToken",
|
||||||
|
);
|
||||||
|
const refreshToken = await signToken(
|
||||||
|
payload,
|
||||||
|
TOKEN_CONFIG.REFRESH_TOKEN_EXPIRY,
|
||||||
|
"refreshToken",
|
||||||
|
);
|
||||||
|
|
||||||
|
await setCookie(TOKEN_CONFIG.ACCESS_TOKEN_COOKIE_NAME, accessToken, {
|
||||||
|
...TOKEN_CONFIG.COOKIE_OPTIONS,
|
||||||
|
expires: add(new Date(), {
|
||||||
|
hours: TOKEN_CONFIG.ACCESS_TOKEN_COOKIE_EXPIRY,
|
||||||
|
}),
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
});
|
||||||
|
await setCookie(TOKEN_CONFIG.REFRESH_TOKEN_COOKIE_NAME, refreshToken, {
|
||||||
|
...TOKEN_CONFIG.COOKIE_OPTIONS,
|
||||||
|
expires: add(new Date(), {
|
||||||
|
days: TOKEN_CONFIG.REFRESH_TOKEN_COOKIE_EXPIRY,
|
||||||
|
}),
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: "success",
|
status: "success" as const,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
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 ({
|
export const userSendConfermaIntent = async ({
|
||||||
servizioId,
|
servizioId,
|
||||||
annuncioId,
|
annuncioId,
|
||||||
|
|
|
||||||
|
|
@ -3,51 +3,25 @@ import type { UsersId, UsersUpdate } from "~/schemas/public/Users";
|
||||||
import type { UsersAnagraficaUpdate } from "~/schemas/public/UsersAnagrafica";
|
import type { UsersAnagraficaUpdate } from "~/schemas/public/UsersAnagrafica";
|
||||||
import type { Context } from "~/server/api/trpc";
|
import type { Context } from "~/server/api/trpc";
|
||||||
import { db, type Querier } from "~/server/db";
|
import { db, type Querier } from "~/server/db";
|
||||||
import { checkCtxAdmin, checkCtxSession } from "~/server/utils/ctxChecker";
|
import { checkCtxAdmin } from "~/server/utils/ctxChecker";
|
||||||
|
|
||||||
type EditAccountInput = {
|
|
||||||
id: UsersId;
|
|
||||||
nome: string;
|
|
||||||
cognome: string;
|
|
||||||
email: string;
|
|
||||||
telefono: string;
|
|
||||||
isAdmin?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const editAccountHandler = async ({
|
export const editAccountHandler = async ({
|
||||||
ctx,
|
id,
|
||||||
input: { id, nome, cognome, email, telefono, isAdmin },
|
data,
|
||||||
}: {
|
}: {
|
||||||
ctx: Context;
|
id: UsersId;
|
||||||
input: EditAccountInput;
|
data: UsersUpdate;
|
||||||
}) => {
|
}) => {
|
||||||
checkCtxSession({ ctx });
|
|
||||||
try {
|
try {
|
||||||
const data: UsersUpdate = {
|
await db
|
||||||
cognome: cognome.trim(),
|
|
||||||
email: email.trim(),
|
|
||||||
nome: nome.trim(),
|
|
||||||
telefono: telefono.replace(/\s/g, ""),
|
|
||||||
username: `${nome.trim()} ${cognome.trim()}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isAdmin !== undefined) {
|
|
||||||
data.isAdmin = isAdmin;
|
|
||||||
}
|
|
||||||
const user = await db
|
|
||||||
.updateTable("users")
|
.updateTable("users")
|
||||||
.set(data)
|
.set({
|
||||||
|
...data,
|
||||||
|
})
|
||||||
.where("id", "=", id)
|
.where("id", "=", id)
|
||||||
.returningAll()
|
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
if (!user) {
|
return "success";
|
||||||
throw new TRPCError({
|
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
|
||||||
message: "errore modifica utente",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return user;
|
|
||||||
} catch {
|
} catch {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
|
|
||||||
|
|
@ -130,12 +130,11 @@ export const pwResetSendLinkHandler = async ({ email }: { email: string }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const passwordOverrideHandler = async ({
|
export const passwordOverrideHandler = async ({
|
||||||
input: { id, password },
|
id,
|
||||||
|
password,
|
||||||
}: {
|
}: {
|
||||||
input: {
|
id: UsersId;
|
||||||
id: UsersId;
|
password: string;
|
||||||
password: string;
|
|
||||||
};
|
|
||||||
}) => {
|
}) => {
|
||||||
const salt = generateSalt();
|
const salt = generateSalt();
|
||||||
const hashedPassword = await hashPassword(password, salt);
|
const hashedPassword = await hashPassword(password, salt);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import EmailContattoInteressato, {
|
||||||
} from "emails/email-interessato";
|
} from "emails/email-interessato";
|
||||||
import type { Generic_NewMail } from "emails/gereric-email";
|
import type { Generic_NewMail } from "emails/gereric-email";
|
||||||
import Generic from "emails/gereric-email";
|
import Generic from "emails/gereric-email";
|
||||||
|
import type { Invito_NewMail } from "emails/invito";
|
||||||
|
import Invito from "emails/invito";
|
||||||
import OnboardingServizio, {
|
import OnboardingServizio, {
|
||||||
type OnboardingServizio_NewMail,
|
type OnboardingServizio_NewMail,
|
||||||
} from "emails/onboarding-servizio";
|
} from "emails/onboarding-servizio";
|
||||||
|
|
@ -89,7 +91,8 @@ export type MailsTemplates =
|
||||||
| VerificaEmail_NewMail
|
| VerificaEmail_NewMail
|
||||||
| OnboardingServizio_NewMail
|
| OnboardingServizio_NewMail
|
||||||
| Generic_NewMail
|
| Generic_NewMail
|
||||||
| SchedaContatto_NewMail;
|
| SchedaContatto_NewMail
|
||||||
|
| Invito_NewMail;
|
||||||
|
|
||||||
export const genMail = async ({ ...mail }: MailsTemplates) => {
|
export const genMail = async ({ ...mail }: MailsTemplates) => {
|
||||||
let mailComponent: JSX.Element | null = null;
|
let mailComponent: JSX.Element | null = null;
|
||||||
|
|
@ -175,6 +178,15 @@ export const genMail = async ({ ...mail }: MailsTemplates) => {
|
||||||
});
|
});
|
||||||
title = "Scheda Contatto Annuncio";
|
title = "Scheda Contatto Annuncio";
|
||||||
break;
|
break;
|
||||||
|
case "invito":
|
||||||
|
mailComponent = Invito({
|
||||||
|
...mail.props,
|
||||||
|
});
|
||||||
|
title = "Invito al sito";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
mailComponent = null;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
if (mailComponent === null) {
|
if (mailComponent === null) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
|
|
|
||||||
|
|
@ -30,3 +30,8 @@ export async function comparePasswords({
|
||||||
export function generateSalt() {
|
export function generateSalt() {
|
||||||
return crypto.randomBytes(16).toString("hex").normalize();
|
return crypto.randomBytes(16).toString("hex").normalize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function generateTemporaryPassword() {
|
||||||
|
// Generate readable temporary password (e.g., "Abc12345")
|
||||||
|
return crypto.randomBytes(4).toString("hex");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,15 @@ export const findUser_byId_MINI = async ({
|
||||||
try {
|
try {
|
||||||
return await db
|
return await db
|
||||||
.selectFrom("users")
|
.selectFrom("users")
|
||||||
.select(["id", "username", "nome", "email", "isAdmin", "isBlocked"])
|
.select([
|
||||||
|
"id",
|
||||||
|
"username",
|
||||||
|
"nome",
|
||||||
|
"email",
|
||||||
|
"isAdmin",
|
||||||
|
"isBlocked",
|
||||||
|
"mustChangePassword",
|
||||||
|
])
|
||||||
.where("id", "=", userId)
|
.where("id", "=", userId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -119,6 +127,7 @@ export const getClientProfilo = async ({
|
||||||
"isAdmin",
|
"isAdmin",
|
||||||
"isBlocked",
|
"isBlocked",
|
||||||
"isAdminMade",
|
"isAdminMade",
|
||||||
|
"mustChangePassword",
|
||||||
])
|
])
|
||||||
.where("id", "=", userId)
|
.where("id", "=", userId)
|
||||||
.leftJoin("users_anagrafica", "users_anagrafica.userid", "users.id")
|
.leftJoin("users_anagrafica", "users_anagrafica.userid", "users.id")
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue