Skip to content

Commit 7041919

Browse files
authored
feat: alternative login (#2159)
1 parent c9af70f commit 7041919

File tree

24 files changed

+1224
-64
lines changed

24 files changed

+1224
-64
lines changed

packages/app/.env.development

+1
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,4 @@ SECURITY_CHARON_URL="https://egapro-charon.dev.fabrique.social.gouv.fr"
8282
SENTRY_DSN=""
8383
# old EGAPRO_FLAVOUR
8484
SENTRY_AUTH_TOKEN=3b4a6e7f1e2346cebd6ad7fa390d66c800dfce8331fc4982a12aafefed1cc47f
85+
EMAIL_LOGIN=true

packages/app/src/api/core-domain/infra/auth/config.ts

+19
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import { type MonCompteProProfile, MonCompteProProvider } from "@api/core-domain/infra/auth/MonCompteProProvider";
2+
import { globalMailerService } from "@api/core-domain/infra/mail";
23
import { ownershipRepo } from "@api/core-domain/repo";
34
import { SyncOwnership } from "@api/core-domain/useCases/SyncOwnership";
45
import { logger } from "@api/utils/pino";
56
import { config } from "@common/config";
67
import { assertImpersonatedSession } from "@common/core-domain/helpers/impersonate";
8+
import { UnexpectedError } from "@common/shared-domain";
9+
import { Email } from "@common/shared-domain/domain/valueObjects";
710
import { Octokit } from "@octokit/rest";
811
import jwt from "jsonwebtoken";
912
import { type AuthOptions, type Session } from "next-auth";
1013
import { type DefaultJWT } from "next-auth/jwt";
14+
import EmailProvider from "next-auth/providers/email";
1115
import GithubProvider, { type GithubProfile } from "next-auth/providers/github";
1216

1317
import { egaproNextAuthAdapter } from "./EgaproNextAuthAdapter";
@@ -66,6 +70,15 @@ export const authConfig: AuthOptions = {
6670
maxAge: config.env === "dev" ? 24 * 60 * 60 * 7 : 24 * 60 * 60, // 24 hours in prod and preprod, 7 days in dev
6771
},
6872
providers: [
73+
EmailProvider({
74+
async sendVerificationRequest({ identifier: to, url }) {
75+
await globalMailerService.init();
76+
const [, rejected] = await globalMailerService.sendMail("login_sendVerificationUrl", { to }, url);
77+
if (rejected.length) {
78+
throw new UnexpectedError(`Cannot send verification request to email(s) : ${rejected.join(", ")}`);
79+
}
80+
},
81+
}),
6982
GithubProvider({
7083
...config.api.security.github,
7184
...(config.env !== "prod"
@@ -145,6 +158,12 @@ export const authConfig: AuthOptions = {
145158
const [firstname, lastname] = githubProfile.name?.split(" ") ?? [];
146159
token.user.firstname = firstname;
147160
token.user.lastname = lastname;
161+
} else if (account?.provider === "email") {
162+
token.user.staff = config.api.staff.includes(profile?.email ?? "");
163+
if (token.email && !token.user.staff) {
164+
const companies = await ownershipRepo.getAllSirenByEmail(new Email(token.email));
165+
token.user.companies = companies.map(siren => ({ label: "", siren }));
166+
}
148167
} else {
149168
const sirenList = profile?.organizations.map(orga => orga.siret.substring(0, 9));
150169
if (profile?.email && sirenList) {

packages/app/src/app/(default)/index-egapro/declaration/commencer/page.tsx

+18-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { authConfig, monCompteProProvider } from "@api/core-domain/infra/auth/config";
22
import { fr } from "@codegouvfr/react-dsfr";
33
import Alert from "@codegouvfr/react-dsfr/Alert";
4+
import { config } from "@common/config";
45
import Link from "next/link";
56
import { getServerSession } from "next-auth";
67

@@ -22,11 +23,27 @@ export const metadata = {
2223
const CommencerPage = async () => {
2324
const session = await getServerSession(authConfig);
2425
if (!session) return null;
26+
const isEmailLogin = config.api.security.auth.isEmailLogin;
2527

2628
const monCompteProHost = monCompteProProvider.issuer;
2729

2830
if (!session.user.companies.length && !session.user.staff) {
29-
return (
31+
return isEmailLogin ? (
32+
<Alert
33+
severity="warning"
34+
className={fr.cx("fr-mb-4w")}
35+
title="Aucune entreprise rattachée"
36+
description={
37+
<>
38+
Nous n'avons trouvé aucune entreprise à laquelle votre compte ({session.user.email}) est rattaché. Si vous
39+
pensez qu'il s'agit d'une erreur, vous pouvez faire une demande de rattachement directement depuis{" "}
40+
<Link href="/rattachement">la page de demande de rattachement</Link>
41+
.<br />
42+
Une fois la demande validée, vous pourrez continuer votre déclaration.
43+
</>
44+
}
45+
/>
46+
) : (
3047
<Alert
3148
severity="warning"
3249
className={fr.cx("fr-mb-4w")}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"use client";
2+
3+
import { fr } from "@codegouvfr/react-dsfr";
4+
import { Alert } from "@codegouvfr/react-dsfr/Alert";
5+
import { Button } from "@codegouvfr/react-dsfr/Button";
6+
import { Input } from "@codegouvfr/react-dsfr/Input";
7+
import { REGEX_EMAIL } from "@common/shared-domain/domain/valueObjects";
8+
import { AlertFeatureStatus, useFeatureStatus } from "@components/utils/FeatureStatusProvider";
9+
import { Container } from "@design-system";
10+
import { zodResolver } from "@hookform/resolvers/zod";
11+
import { signIn } from "next-auth/react";
12+
import { useForm } from "react-hook-form";
13+
import { z } from "zod";
14+
15+
const formSchema = z.object({
16+
email: z
17+
.string()
18+
.min(1, "L'adresse email est requise.")
19+
.regex(REGEX_EMAIL, { message: "L'adresse email est invalide." }),
20+
});
21+
22+
type FormType = z.infer<typeof formSchema>;
23+
24+
export interface EmailAuthticatorProps {
25+
callbackUrl: string;
26+
}
27+
export const EmailLogin = ({ callbackUrl }: EmailAuthticatorProps) => {
28+
const { featureStatus, setFeatureStatus } = useFeatureStatus();
29+
30+
const {
31+
register,
32+
handleSubmit,
33+
watch,
34+
formState: { errors, isValid },
35+
} = useForm<FormType>({
36+
resolver: zodResolver(formSchema),
37+
defaultValues: {
38+
email: "",
39+
},
40+
});
41+
42+
const email = watch("email");
43+
44+
const onSubmit = async ({ email }: FormType) => {
45+
try {
46+
setFeatureStatus({ type: "loading" });
47+
const result = await signIn("email", { email, callbackUrl, redirect: false });
48+
if (result?.ok) {
49+
setFeatureStatus({ type: "success", message: "Un email vous a été envoyé." });
50+
} else {
51+
setFeatureStatus({
52+
type: "error",
53+
message: `Erreur lors de l'envoi de l'email. (${result?.status}) ${result?.error}`,
54+
});
55+
}
56+
} catch (error) {
57+
setFeatureStatus({
58+
type: "error",
59+
message: "Erreur lors de l'envoi de l'email, veuillez vérifier que l'adresse est correcte.",
60+
});
61+
}
62+
};
63+
64+
return (
65+
<>
66+
<AlertFeatureStatus title="Erreur" type="error" />
67+
68+
{featureStatus.type === "success" && (
69+
<>
70+
<p>Vous allez recevoir un mail sur l'adresse email que vous avez indiquée à l'étape précédente.</p>
71+
72+
<p>
73+
<strong>Ouvrez ce mail et cliquez sur le lien de validation.</strong>
74+
</p>
75+
<p>
76+
Si vous ne recevez pas ce mail sous peu, il se peut que l'email saisi (<strong>{email}</strong>) soit
77+
incorrect, ou bien que le mail ait été déplacé dans votre dossier de courriers indésirables ou dans le
78+
dossier SPAM.
79+
</p>
80+
<p>En cas d'échec, la procédure devra être reprise avec un autre email.</p>
81+
82+
<Button onClick={() => setFeatureStatus({ type: "idle" })} className={fr.cx("fr-mt-4w")}>
83+
Réessayer
84+
</Button>
85+
</>
86+
)}
87+
88+
{featureStatus.type !== "success" && (
89+
<>
90+
<Alert
91+
severity="info"
92+
title="Attention"
93+
description="En cas d'email erroné, vous ne pourrez pas remplir le formulaire ou accéder à votre déclaration déjà transmise."
94+
/>
95+
96+
<p className={fr.cx("fr-mt-4w")}>
97+
Pour pouvoir permettre de poursuivre la transmission des informations requises, l’email doit correspondre à
98+
celui de la personne à contacter par les services de l’inspection du travail en cas de besoin et sera celui
99+
sur lequel sera adressé l’accusé de réception en fin de procédure.
100+
</p>
101+
102+
<p>
103+
Si vous souhaitez visualiser ou modifier votre déclaration déjà transmise, veuillez saisir l'email utilisé
104+
pour la déclaration.
105+
</p>
106+
107+
<form onSubmit={handleSubmit(onSubmit)} noValidate>
108+
<Container fluid mt="4w">
109+
<Input
110+
label="Adresse email"
111+
state={errors.email?.message ? "error" : "default"}
112+
stateRelatedMessage={errors.email?.message}
113+
nativeInputProps={{
114+
...register("email"),
115+
type: "email",
116+
spellCheck: false,
117+
autoComplete: "email",
118+
placeholder: "Exemple : [email protected]",
119+
}}
120+
/>
121+
<Button disabled={featureStatus.type === "loading" || !isValid}>Envoyer</Button>
122+
</Container>
123+
</form>
124+
</>
125+
)}
126+
</>
127+
);
128+
};

packages/app/src/app/(default)/login/page.tsx

+38-27
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { authConfig } from "@api/core-domain/infra/auth/config";
22
import Alert from "@codegouvfr/react-dsfr/Alert";
3+
import { config } from "@common/config";
34
import { type NextServerPageProps } from "@common/utils/next";
45
import { Box, CenteredContainer } from "@design-system";
56
import { getServerSession } from "next-auth";
67

8+
import { EmailLogin } from "./EmailLogin";
79
import { GithubLogin } from "./GithubLogin";
810
import { MonCompteProLogin } from "./MonCompteProLogin";
911

@@ -36,6 +38,7 @@ const LoginPage = async ({ searchParams }: NextServerPageProps<never, "callbackU
3638
const session = await getServerSession(authConfig);
3739
const callbackUrl = typeof searchParams.callbackUrl === "string" ? searchParams.callbackUrl : "";
3840
const error = typeof searchParams.error === "string" ? searchParams.error : "";
41+
const isEmailLogin = config.api.security.auth.isEmailLogin;
3942

4043
return (
4144
<CenteredContainer py="6w">
@@ -54,35 +57,43 @@ const LoginPage = async ({ searchParams }: NextServerPageProps<never, "callbackU
5457
<br />
5558
</>
5659
)}
57-
<Alert
58-
severity="info"
59-
small
60-
description={
60+
{!isEmailLogin && (
61+
<Alert
62+
severity="info"
63+
small
64+
description={
65+
<>
66+
<p>
67+
Egapro utilise le service d’identification MonComptePro afin de garantir l’appartenance de ses
68+
utilisateurs aux entreprises déclarantes.
69+
</p>
70+
<br />
71+
<p>
72+
Pour s'identifier avec MonComptePro, il convient d'utiliser une <b>adresse mail professionnelle</b>,
73+
celle-ci doit correspondre à la personne à contacter par les services de l'inspection du travail en
74+
cas de besoin.
75+
</p>
76+
<br />
77+
<p>
78+
<strong>
79+
Les tiers déclarants (comptables...) ne sont pas autorisés à déclarer pour le compte de leur
80+
entreprise cliente. Cette dernière doit créer son propre compte MonComptePro pour déclarer sur
81+
Egapro.
82+
</strong>
83+
</p>
84+
</>
85+
}
86+
/>
87+
)}
88+
<Box className="text-center" mt="2w">
89+
{isEmailLogin ? (
90+
<EmailLogin callbackUrl={callbackUrl} />
91+
) : (
6192
<>
62-
<p>
63-
Egapro utilise le service d’identification MonComptePro afin de garantir l’appartenance de ses
64-
utilisateurs aux entreprises déclarantes.
65-
</p>
66-
<br />
67-
<p>
68-
Pour s'identifier avec MonComptePro, il convient d'utiliser une <b>adresse mail professionnelle</b>,
69-
celle-ci doit correspondre à la personne à contacter par les services de l'inspection du travail en
70-
cas de besoin.
71-
</p>
72-
<br />
73-
<p>
74-
<strong>
75-
Les tiers déclarants (comptables...) ne sont pas autorisés à déclarer pour le compte de leur
76-
entreprise cliente. Cette dernière doit créer son propre compte MonComptePro pour déclarer sur
77-
Egapro.
78-
</strong>
79-
</p>
93+
<MonCompteProLogin callbackUrl={callbackUrl} />
94+
<GithubLogin callbackUrl={callbackUrl} />
8095
</>
81-
}
82-
/>
83-
<Box className="text-center" mt="2w">
84-
<MonCompteProLogin callbackUrl={callbackUrl} />
85-
<GithubLogin callbackUrl={callbackUrl} />
96+
)}
8697
</Box>
8798
</>
8899
)}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"use client";
2+
3+
import Button from "@codegouvfr/react-dsfr/Button";
4+
import { Input } from "@codegouvfr/react-dsfr/Input";
5+
import { Container } from "@design-system";
6+
import { zodResolver } from "@hookform/resolvers/zod";
7+
import { useRouter } from "next/navigation";
8+
import { useForm } from "react-hook-form";
9+
import { z } from "zod";
10+
11+
import { addSirens } from "./actions";
12+
13+
const formSchema = z.object({
14+
email: z.string().min(1, "L'adresse email est requise.").email("L'adresse email est invalide."),
15+
});
16+
17+
type FormType = z.infer<typeof formSchema>;
18+
19+
export const AddOwnershipForm = ({ siren }: { siren: string }) => {
20+
const router = useRouter();
21+
const {
22+
register,
23+
handleSubmit,
24+
setValue,
25+
formState: { errors, isValid },
26+
} = useForm<FormType>({
27+
resolver: zodResolver(formSchema),
28+
defaultValues: {
29+
email: "",
30+
},
31+
});
32+
33+
const onSubmit = async ({ email }: FormType) => {
34+
await addSirens(email, [siren]);
35+
setValue("email", "");
36+
router.refresh();
37+
};
38+
39+
return (
40+
<form onSubmit={handleSubmit(onSubmit)} noValidate>
41+
<Container fluid mt="4w">
42+
<Input
43+
label="Adresse email"
44+
state={errors.email?.message ? "error" : "default"}
45+
stateRelatedMessage={errors.email?.message}
46+
nativeInputProps={{
47+
...register("email"),
48+
type: "email",
49+
spellCheck: false,
50+
autoComplete: "email",
51+
placeholder: "Exemple : [email protected]",
52+
}}
53+
/>
54+
<Button disabled={!isValid} type="submit">
55+
Ajouter un responsable
56+
</Button>
57+
</Container>
58+
</form>
59+
);
60+
};

0 commit comments

Comments
 (0)