Skip to content

Commit

Permalink
feat: alternative login (#2159)
Browse files Browse the repository at this point in the history
  • Loading branch information
jonat75 authored Feb 26, 2024
1 parent c9af70f commit 7041919
Show file tree
Hide file tree
Showing 24 changed files with 1,224 additions and 64 deletions.
1 change: 1 addition & 0 deletions packages/app/.env.development
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,4 @@ SECURITY_CHARON_URL="https://egapro-charon.dev.fabrique.social.gouv.fr"
SENTRY_DSN=""
# old EGAPRO_FLAVOUR
SENTRY_AUTH_TOKEN=3b4a6e7f1e2346cebd6ad7fa390d66c800dfce8331fc4982a12aafefed1cc47f
EMAIL_LOGIN=true
19 changes: 19 additions & 0 deletions packages/app/src/api/core-domain/infra/auth/config.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { type MonCompteProProfile, MonCompteProProvider } from "@api/core-domain/infra/auth/MonCompteProProvider";
import { globalMailerService } from "@api/core-domain/infra/mail";
import { ownershipRepo } from "@api/core-domain/repo";
import { SyncOwnership } from "@api/core-domain/useCases/SyncOwnership";
import { logger } from "@api/utils/pino";
import { config } from "@common/config";
import { assertImpersonatedSession } from "@common/core-domain/helpers/impersonate";
import { UnexpectedError } from "@common/shared-domain";
import { Email } from "@common/shared-domain/domain/valueObjects";
import { Octokit } from "@octokit/rest";
import jwt from "jsonwebtoken";
import { type AuthOptions, type Session } from "next-auth";
import { type DefaultJWT } from "next-auth/jwt";
import EmailProvider from "next-auth/providers/email";
import GithubProvider, { type GithubProfile } from "next-auth/providers/github";

import { egaproNextAuthAdapter } from "./EgaproNextAuthAdapter";
Expand Down Expand Up @@ -66,6 +70,15 @@ export const authConfig: AuthOptions = {
maxAge: config.env === "dev" ? 24 * 60 * 60 * 7 : 24 * 60 * 60, // 24 hours in prod and preprod, 7 days in dev
},
providers: [
EmailProvider({
async sendVerificationRequest({ identifier: to, url }) {
await globalMailerService.init();
const [, rejected] = await globalMailerService.sendMail("login_sendVerificationUrl", { to }, url);
if (rejected.length) {
throw new UnexpectedError(`Cannot send verification request to email(s) : ${rejected.join(", ")}`);
}
},
}),
GithubProvider({
...config.api.security.github,
...(config.env !== "prod"
Expand Down Expand Up @@ -145,6 +158,12 @@ export const authConfig: AuthOptions = {
const [firstname, lastname] = githubProfile.name?.split(" ") ?? [];
token.user.firstname = firstname;
token.user.lastname = lastname;
} else if (account?.provider === "email") {
token.user.staff = config.api.staff.includes(profile?.email ?? "");
if (token.email && !token.user.staff) {
const companies = await ownershipRepo.getAllSirenByEmail(new Email(token.email));
token.user.companies = companies.map(siren => ({ label: "", siren }));
}
} else {
const sirenList = profile?.organizations.map(orga => orga.siret.substring(0, 9));
if (profile?.email && sirenList) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { authConfig, monCompteProProvider } from "@api/core-domain/infra/auth/config";
import { fr } from "@codegouvfr/react-dsfr";
import Alert from "@codegouvfr/react-dsfr/Alert";
import { config } from "@common/config";
import Link from "next/link";
import { getServerSession } from "next-auth";

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

const monCompteProHost = monCompteProProvider.issuer;

if (!session.user.companies.length && !session.user.staff) {
return (
return isEmailLogin ? (
<Alert
severity="warning"
className={fr.cx("fr-mb-4w")}
title="Aucune entreprise rattachée"
description={
<>
Nous n'avons trouvé aucune entreprise à laquelle votre compte ({session.user.email}) est rattaché. Si vous
pensez qu'il s'agit d'une erreur, vous pouvez faire une demande de rattachement directement depuis{" "}
<Link href="/rattachement">la page de demande de rattachement</Link>
.<br />
Une fois la demande validée, vous pourrez continuer votre déclaration.
</>
}
/>
) : (
<Alert
severity="warning"
className={fr.cx("fr-mb-4w")}
Expand Down
128 changes: 128 additions & 0 deletions packages/app/src/app/(default)/login/EmailLogin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"use client";

import { fr } from "@codegouvfr/react-dsfr";
import { Alert } from "@codegouvfr/react-dsfr/Alert";
import { Button } from "@codegouvfr/react-dsfr/Button";
import { Input } from "@codegouvfr/react-dsfr/Input";
import { REGEX_EMAIL } from "@common/shared-domain/domain/valueObjects";
import { AlertFeatureStatus, useFeatureStatus } from "@components/utils/FeatureStatusProvider";
import { Container } from "@design-system";
import { zodResolver } from "@hookform/resolvers/zod";
import { signIn } from "next-auth/react";
import { useForm } from "react-hook-form";
import { z } from "zod";

const formSchema = z.object({
email: z
.string()
.min(1, "L'adresse email est requise.")
.regex(REGEX_EMAIL, { message: "L'adresse email est invalide." }),
});

type FormType = z.infer<typeof formSchema>;

export interface EmailAuthticatorProps {
callbackUrl: string;
}
export const EmailLogin = ({ callbackUrl }: EmailAuthticatorProps) => {
const { featureStatus, setFeatureStatus } = useFeatureStatus();

const {
register,
handleSubmit,
watch,
formState: { errors, isValid },
} = useForm<FormType>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
},
});

const email = watch("email");

const onSubmit = async ({ email }: FormType) => {
try {
setFeatureStatus({ type: "loading" });
const result = await signIn("email", { email, callbackUrl, redirect: false });
if (result?.ok) {
setFeatureStatus({ type: "success", message: "Un email vous a été envoyé." });
} else {
setFeatureStatus({
type: "error",
message: `Erreur lors de l'envoi de l'email. (${result?.status}) ${result?.error}`,
});
}
} catch (error) {
setFeatureStatus({
type: "error",
message: "Erreur lors de l'envoi de l'email, veuillez vérifier que l'adresse est correcte.",
});
}
};

return (
<>
<AlertFeatureStatus title="Erreur" type="error" />

{featureStatus.type === "success" && (
<>
<p>Vous allez recevoir un mail sur l'adresse email que vous avez indiquée à l'étape précédente.</p>

<p>
<strong>Ouvrez ce mail et cliquez sur le lien de validation.</strong>
</p>
<p>
Si vous ne recevez pas ce mail sous peu, il se peut que l'email saisi (<strong>{email}</strong>) soit
incorrect, ou bien que le mail ait été déplacé dans votre dossier de courriers indésirables ou dans le
dossier SPAM.
</p>
<p>En cas d'échec, la procédure devra être reprise avec un autre email.</p>

<Button onClick={() => setFeatureStatus({ type: "idle" })} className={fr.cx("fr-mt-4w")}>
Réessayer
</Button>
</>
)}

{featureStatus.type !== "success" && (
<>
<Alert
severity="info"
title="Attention"
description="En cas d'email erroné, vous ne pourrez pas remplir le formulaire ou accéder à votre déclaration déjà transmise."
/>

<p className={fr.cx("fr-mt-4w")}>
Pour pouvoir permettre de poursuivre la transmission des informations requises, l’email doit correspondre à
celui de la personne à contacter par les services de l’inspection du travail en cas de besoin et sera celui
sur lequel sera adressé l’accusé de réception en fin de procédure.
</p>

<p>
Si vous souhaitez visualiser ou modifier votre déclaration déjà transmise, veuillez saisir l'email utilisé
pour la déclaration.
</p>

<form onSubmit={handleSubmit(onSubmit)} noValidate>
<Container fluid mt="4w">
<Input
label="Adresse email"
state={errors.email?.message ? "error" : "default"}
stateRelatedMessage={errors.email?.message}
nativeInputProps={{
...register("email"),
type: "email",
spellCheck: false,
autoComplete: "email",
placeholder: "Exemple : [email protected]",
}}
/>
<Button disabled={featureStatus.type === "loading" || !isValid}>Envoyer</Button>
</Container>
</form>
</>
)}
</>
);
};
65 changes: 38 additions & 27 deletions packages/app/src/app/(default)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { authConfig } from "@api/core-domain/infra/auth/config";
import Alert from "@codegouvfr/react-dsfr/Alert";
import { config } from "@common/config";
import { type NextServerPageProps } from "@common/utils/next";
import { Box, CenteredContainer } from "@design-system";
import { getServerSession } from "next-auth";

import { EmailLogin } from "./EmailLogin";
import { GithubLogin } from "./GithubLogin";
import { MonCompteProLogin } from "./MonCompteProLogin";

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

return (
<CenteredContainer py="6w">
Expand All @@ -54,35 +57,43 @@ const LoginPage = async ({ searchParams }: NextServerPageProps<never, "callbackU
<br />
</>
)}
<Alert
severity="info"
small
description={
{!isEmailLogin && (
<Alert
severity="info"
small
description={
<>
<p>
Egapro utilise le service d’identification MonComptePro afin de garantir l’appartenance de ses
utilisateurs aux entreprises déclarantes.
</p>
<br />
<p>
Pour s'identifier avec MonComptePro, il convient d'utiliser une <b>adresse mail professionnelle</b>,
celle-ci doit correspondre à la personne à contacter par les services de l'inspection du travail en
cas de besoin.
</p>
<br />
<p>
<strong>
Les tiers déclarants (comptables...) ne sont pas autorisés à déclarer pour le compte de leur
entreprise cliente. Cette dernière doit créer son propre compte MonComptePro pour déclarer sur
Egapro.
</strong>
</p>
</>
}
/>
)}
<Box className="text-center" mt="2w">
{isEmailLogin ? (
<EmailLogin callbackUrl={callbackUrl} />
) : (
<>
<p>
Egapro utilise le service d’identification MonComptePro afin de garantir l’appartenance de ses
utilisateurs aux entreprises déclarantes.
</p>
<br />
<p>
Pour s'identifier avec MonComptePro, il convient d'utiliser une <b>adresse mail professionnelle</b>,
celle-ci doit correspondre à la personne à contacter par les services de l'inspection du travail en
cas de besoin.
</p>
<br />
<p>
<strong>
Les tiers déclarants (comptables...) ne sont pas autorisés à déclarer pour le compte de leur
entreprise cliente. Cette dernière doit créer son propre compte MonComptePro pour déclarer sur
Egapro.
</strong>
</p>
<MonCompteProLogin callbackUrl={callbackUrl} />
<GithubLogin callbackUrl={callbackUrl} />
</>
}
/>
<Box className="text-center" mt="2w">
<MonCompteProLogin callbackUrl={callbackUrl} />
<GithubLogin callbackUrl={callbackUrl} />
)}
</Box>
</>
)}
Expand Down
60 changes: 60 additions & 0 deletions packages/app/src/app/(default)/mon-espace/AddOwnershipForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"use client";

import Button from "@codegouvfr/react-dsfr/Button";
import { Input } from "@codegouvfr/react-dsfr/Input";
import { Container } from "@design-system";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { z } from "zod";

import { addSirens } from "./actions";

const formSchema = z.object({
email: z.string().min(1, "L'adresse email est requise.").email("L'adresse email est invalide."),
});

type FormType = z.infer<typeof formSchema>;

export const AddOwnershipForm = ({ siren }: { siren: string }) => {
const router = useRouter();
const {
register,
handleSubmit,
setValue,
formState: { errors, isValid },
} = useForm<FormType>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
},
});

const onSubmit = async ({ email }: FormType) => {
await addSirens(email, [siren]);
setValue("email", "");
router.refresh();
};

return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<Container fluid mt="4w">
<Input
label="Adresse email"
state={errors.email?.message ? "error" : "default"}
stateRelatedMessage={errors.email?.message}
nativeInputProps={{
...register("email"),
type: "email",
spellCheck: false,
autoComplete: "email",
placeholder: "Exemple : [email protected]",
}}
/>
<Button disabled={!isValid} type="submit">
Ajouter un responsable
</Button>
</Container>
</form>
);
};
Loading

0 comments on commit 7041919

Please sign in to comment.