Skip to content

Commit

Permalink
feat: add staff impersonation (#1753)
Browse files Browse the repository at this point in the history
  • Loading branch information
lsagetlethias authored Sep 6, 2023
1 parent c091d8d commit 4afe85e
Show file tree
Hide file tree
Showing 21 changed files with 445 additions and 63 deletions.
55 changes: 36 additions & 19 deletions packages/app/src/api/core-domain/infra/auth/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type MonCompteProProfile, MonCompteProProvider } from "@api/core-domain/infra/auth/MonCompteProProvider";
import { config } from "@common/config";
import { assertImpersonatedSession } from "@common/core-domain/helpers/impersonate";
import { Octokit } from "@octokit/rest";
import jwt from "jsonwebtoken";
import { type AuthOptions, type Session } from "next-auth";
Expand All @@ -10,6 +11,9 @@ import { egaproNextAuthAdapter } from "./EgaproNextAuthAdapter";

declare module "next-auth" {
interface Session {
staff?: {
impersonating: boolean;
};
user: {
companies: Array<{ label: string | null; siren: string }>;
email: string;
Expand All @@ -26,8 +30,7 @@ declare module "next-auth" {
}

declare module "next-auth/jwt" {
type UserSession = Session["user"];
interface JWT extends DefaultJWT, UserSession {
interface JWT extends DefaultJWT, Session {
email: string;
}
}
Expand Down Expand Up @@ -106,40 +109,54 @@ export const authConfig: AuthOptions = {
},
// prefill JWT encoded data with staff and ownership on signup
// by design user always "signup" from our pov because we don't save user accounts
async jwt({ token, profile, trigger, account }) {
async jwt({ token, profile, trigger, account, session }) {
if (trigger === "update" && session && token.user.staff) {
if (session.staff.impersonating === true) {
// staff starts impersonating
assertImpersonatedSession(session);
token.user.staff = session.user.staff;
token.user.companies = session.user.companies;
token.staff = { impersonating: true };
} else if (session.staff.impersonating === false) {
// staff stops impersonating
token.user.staff = true;
token.user.companies = [];
token.staff = { impersonating: false };
}
}
if (trigger !== "signUp") return token;
token.user = {} as Session["user"];
if (account?.provider === "github") {
const githubProfile = profile as unknown as GithubProfile;
token.staff = true;
token.companies = [];
token.user.staff = true;
token.user.companies = [];
const [firstname, lastname] = githubProfile.name?.split(" ") ?? [];
token.firstname = firstname;
token.lastname = lastname;
token.user.firstname = firstname;
token.user.lastname = lastname;
} else {
token.companies =
token.user.companies =
profile?.organizations.map(orga => ({
siren: orga.siret.substring(0, 9),
label: orga.label,
})) ?? [];
token.staff = false;
token.firstname = profile?.given_name ?? void 0;
token.lastname = profile?.family_name ?? void 0;
token.phoneNumber = profile?.phone_number ?? void 0;
token.user.staff = false;
token.user.firstname = profile?.given_name ?? void 0;
token.user.lastname = profile?.family_name ?? void 0;
token.user.phoneNumber = profile?.phone_number ?? void 0;
}

// Token legacy for usage with API v1.
token.tokenApiV1 = createTokenApiV1(token.email);
token.tokenApiV1 = createTokenApiV1(token.user.email);

return token;
},
// expose data from jwt to front
session({ session, token }) {
session.user.staff = token.staff;
session.user.companies = token.companies;
session.user.firstname = token.firstname;
session.user.lastname = token.lastname;
session.user.phoneNumber = token.phoneNumber;
session.user.tokenApiV1 = token.tokenApiV1;
session.user = token.user;
session.user.email = token.email;
if (token.staff && (token.user.staff || token.staff.impersonating)) {
session.staff = token.staff;
}
return session;
},
},
Expand Down
4 changes: 3 additions & 1 deletion packages/app/src/app/(default)/Navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export const Navigation = () => {

const { data: session } = useSession();

const isStaff = session?.user.staff || session?.staff?.impersonating || false;

return (
<MainNavigation
items={[
Expand Down Expand Up @@ -56,7 +58,7 @@ export const Navigation = () => {
},
isActive: segment === "representation-equilibree",
},
...(session?.user.staff
...(isStaff
? [
{
text: "Admin",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Select from "@codegouvfr/react-dsfr/Select";
import { createSteps } from "@common/core-domain/dtos/CreateRepresentationEquilibreeDTO";
import { YEARS_REPEQ } from "@common/dict";
import { BackNextButtonsGroup, FormLayout } from "@design-system";
import { getCompany } from "@globalActions/company";
import { zodResolver } from "@hookform/resolvers/zod";
import { sortBy } from "lodash";
import { useRouter } from "next/navigation";
Expand All @@ -13,7 +14,7 @@ import { useTransition } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";

import { getCompany, getRepresentationEquilibree } from "../../actions";
import { getRepresentationEquilibree } from "../../actions";
import { useRepeqFunnelStore } from "../useRepeqFunnelStore";

type CommencerFormType = z.infer<typeof createSteps.commencer>;
Expand Down Expand Up @@ -57,31 +58,30 @@ export const CommencerForm = ({ session }: { session: Session }) => {

const saveAndGoNext = async (siren: string, year: number) =>
startTransition(async () => {
try {
// Synchronise with potential data in DB.
const exists = await getRepresentationEquilibree(siren, year);
if (exists) {
return router.push(`/representation-equilibree/${siren}/${year}`);
}

const company = await getCompany(siren);
if (company.dateCessation) {
return setError("siren", {
type: "manual",
message: "Le Siren saisi correspond à une entreprise fermée, veuillez vérifier votre saisie",
});
}
// Synchronise with potential data in DB.
const exists = await getRepresentationEquilibree(siren, year);
if (exists) {
return router.push(`/representation-equilibree/${siren}/${year}`);
}

// Otherwise, this is a creation.
setIsEdit(false);
resetFunnel();
saveFunnel({ year, siren });
router.push("/representation-equilibree/declarant");
} catch (error) {
// We can't continue in this case, because the backend is not ready.
console.error("Unexpected API error", error);
resetFunnel();
const company = await getCompany(siren);
if (!company.ok) {
return setError("siren", {
type: "manual",
message: "Erreur lors de la récupération des données de l'entreprise, veuillez vérifier votre saisie",
});
} else if (company.data.dateCessation) {
return setError("siren", {
type: "manual",
message: "Le Siren saisi correspond à une entreprise fermée, veuillez vérifier votre saisie",
});
}

// Otherwise, this is a creation.
setIsEdit(false);
resetFunnel();
saveFunnel({ year, siren });
router.push("/representation-equilibree/declarant");
});

const onSubmit = async ({ siren, year }: CommencerFormType) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import { getAdditionalMeta } from "@common/core-domain/helpers/entreprise";
import { COUNTRIES_COG_TO_ISO } from "@common/dict";
import { SkeletonFlex } from "@components/utils/skeleton/SkeletonFlex";
import { BackNextButtonsGroup, FormLayout, RecapCard, RecapCardCompany } from "@design-system";
import { getCompany } from "@globalActions/company";
import { redirect } from "next/navigation";
import { useEffect, useState } from "react";
import Skeleton from "react-loading-skeleton";

import { getCompany } from "../../actions";
import { useRepeqFunnelStore, useRepeqFunnelStoreHasHydrated } from "../useRepeqFunnelStore";

export const EntrepriseForm = () => {
Expand All @@ -19,7 +19,15 @@ export const EntrepriseForm = () => {
const hydrated = useRepeqFunnelStoreHasHydrated();

useEffect(() => {
if (funnel?.siren && !company) getCompany(funnel.siren).then(setCompany);
if (funnel?.siren && !company) {
getCompany(funnel.siren).then(company => {
if (company.ok) {
setCompany(company.data);
} else {
throw new Error(`Could not fetch company with siren ${funnel.siren} (code ${company.error})`);
}
});
}
}, [funnel, company]);

if (!hydrated || !company) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@ import { COUNTRIES_COG_TO_ISO } from "@common/dict";
import { storePicker } from "@common/utils/zustand";
import { SkeletonFlex } from "@components/utils/skeleton/SkeletonFlex";
import { RecapCard } from "@design-system";
import { getCompany } from "@globalActions/company";
import { times } from "lodash";
import { redirect, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import Skeleton from "react-loading-skeleton";
import { type ZodError } from "zod";

import { getCompany, saveRepresentationEquilibree } from "../../actions";
import { saveRepresentationEquilibree } from "../../actions";
import { DetailRepEq } from "../../Recap";
import { useRepeqFunnelStore, useRepeqFunnelStoreHasHydrated } from "../useRepeqFunnelStore";

Expand All @@ -38,7 +39,15 @@ export const ValidationRecapRepEq = () => {
const hydrated = useRepeqFunnelStoreHasHydrated();

useEffect(() => {
if (funnel?.siren && !company) getCompany(funnel.siren).then(setCompany);
if (funnel?.siren && !company) {
getCompany(funnel.siren).then(company => {
if (company.ok) {
setCompany(company.data);
} else {
throw new Error(`Could not fetch company with siren ${funnel.siren} (code ${company.error})`);
}
});
}
}, [funnel, company]);

if (hydrated && !funnel?.siren) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
} from "@api/core-domain/useCases/SendRepresentationEquilibreeReceipt";
import { jsxPdfService } from "@api/shared-domain/infra/pdf";
import { assertServerSession } from "@api/utils/auth";
import { Siren } from "@common/core-domain/domain/valueObjects/Siren";
import { type CreateRepresentationEquilibreeDTO } from "@common/core-domain/dtos/CreateRepresentationEquilibreeDTO";
import { revalidatePath } from "next/cache";

Expand All @@ -31,10 +30,6 @@ export async function getRepresentationEquilibree(siren: string, year: number) {
return ret;
}

export async function getCompany(siren: string) {
return entrepriseService.siren(new Siren(siren));
}

export async function saveRepresentationEquilibree(repEq: CreateRepresentationEquilibreeDTO) {
const session = await assertServerSession({
owner: {
Expand Down
6 changes: 4 additions & 2 deletions packages/app/src/app/AuthHeaderItems.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,16 @@ import Skeleton from "react-loading-skeleton";
export const UserHeaderItem = () => {
const session = useSession();

let isStaff = false;
switch (session.status) {
case "authenticated":
isStaff = session.data.user.staff || session.data.staff?.impersonating || false;
return (
<HeaderQuickAccessItem
key="hqai-authenticated-user"
quickAccessItem={{
iconId: session.data.user.staff ? "fr-icon-github-line" : "fr-icon-account-fill",
text: `${session.data.user.email}${session.data.user.staff ? " (staff)" : ""}`,
iconId: isStaff ? "fr-icon-github-line" : "fr-icon-account-fill",
text: `${session.data.user.email}${isStaff ? " (staff)" : ""}`,
linkProps: { href: "/index-egapro/tableauDeBord/mon-profil" },
}}
/>
Expand Down
28 changes: 28 additions & 0 deletions packages/app/src/app/ImpersonateNotice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"use client";

import { Notice } from "@codegouvfr/react-dsfr/Notice";
import { useSession } from "next-auth/react";

import { Box } from "../design-system/base/Box";

export const ImpersonateNotice = () => {
const session = useSession();

if (session.status !== "authenticated") return null;

const isImpersonating = session.data.staff?.impersonating || false;

if (!isImpersonating) return null;

const { siren, label } = session.data.user.companies[0];

return (
<>
<Notice
style={{ position: "fixed", zIndex: 99999, width: "100%", marginTop: "-3.5rem" }}
title={`Vous êtes actuellement dans la peau du Siren "${siren}" (${label}). Pour arrêter, rendez-vous sur la page d'admin.`}
/>
<Box mt="7w" />
</>
);
};
22 changes: 22 additions & 0 deletions packages/app/src/app/_globalActions/company.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"use server";

import { entrepriseService } from "@api/core-domain/infra/services";
import { type Entreprise, EntrepriseServiceNotFoundError } from "@api/core-domain/infra/services/IEntrepriseService";
import { Siren } from "@common/core-domain/domain/valueObjects/Siren";
import { type ServerActionResponse } from "@common/utils/next";

import { CompanyErrorCodes } from "./companyErrorCodes";

export async function getCompany(siren: string): Promise<ServerActionResponse<Entreprise, CompanyErrorCodes>> {
try {
return {
data: await entrepriseService.siren(new Siren(siren)),
ok: true,
};
} catch (error: unknown) {
return {
ok: false,
error: error instanceof EntrepriseServiceNotFoundError ? CompanyErrorCodes.NOT_FOUND : CompanyErrorCodes.UNKNOWN,
};
}
}
5 changes: 5 additions & 0 deletions packages/app/src/app/_globalActions/companyErrorCodes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export enum CompanyErrorCodes {
NOT_FOUND,
UNKNOWN,
ABORTED = 999,
}
5 changes: 5 additions & 0 deletions packages/app/src/app/admin/Navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ export const adminMenuItems = [
href: "/admin/debug",
segment: "debug",
},
{
text: "Mimoquer un SIREN",
href: "/admin/impersonate",
segment: "impersonate",
},
];

export const Navigation = () => {
Expand Down
Loading

0 comments on commit 4afe85e

Please sign in to comment.