Skip to content

Commit badc16a

Browse files
feat(admin): mimoquage (impersonation) d'une entreprise (#3188)
1 parent c0967bd commit badc16a

32 files changed

Lines changed: 1271 additions & 37 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
-- Admin impersonation audit log: one row per "mimoquage" session started by
2+
-- an admin. Used for audit trail and as the source for the recently
3+
-- impersonated quick-pick list in the backoffice UI.
4+
CREATE TABLE IF NOT EXISTS "app_admin_impersonation_event" (
5+
"id" varchar(255) PRIMARY KEY NOT NULL,
6+
"admin_user_id" varchar(255) NOT NULL,
7+
"siren" varchar(9) NOT NULL,
8+
"started_at" timestamp with time zone NOT NULL,
9+
"stopped_at" timestamp with time zone
10+
);
11+
--> statement-breakpoint
12+
ALTER TABLE "app_admin_impersonation_event"
13+
ADD CONSTRAINT "app_admin_impersonation_event_admin_user_id_app_user_id_fk"
14+
FOREIGN KEY ("admin_user_id") REFERENCES "app_user"("id") ON DELETE no action ON UPDATE no action;
15+
--> statement-breakpoint
16+
ALTER TABLE "app_admin_impersonation_event"
17+
ADD CONSTRAINT "app_admin_impersonation_event_siren_app_company_siren_fk"
18+
FOREIGN KEY ("siren") REFERENCES "app_company"("siren") ON DELETE no action ON UPDATE no action;
19+
--> statement-breakpoint
20+
CREATE INDEX IF NOT EXISTS "admin_impersonation_event_admin_started_idx"
21+
ON "app_admin_impersonation_event" ("admin_user_id", "started_at");
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
-- Admin impersonation audit log: one row per "mimoquage" session started by
2+
-- an admin. Used for audit trail and as the source for the recently
3+
-- impersonated quick-pick list in the backoffice UI.
4+
CREATE TABLE IF NOT EXISTS "app_admin_impersonation_event" (
5+
"id" varchar(255) PRIMARY KEY NOT NULL,
6+
"admin_user_id" varchar(255) NOT NULL,
7+
"siren" varchar(9) NOT NULL,
8+
"started_at" timestamp with time zone NOT NULL,
9+
"stopped_at" timestamp with time zone
10+
);
11+
--> statement-breakpoint
12+
ALTER TABLE "app_admin_impersonation_event"
13+
ADD CONSTRAINT "app_admin_impersonation_event_admin_user_id_app_user_id_fk"
14+
FOREIGN KEY ("admin_user_id") REFERENCES "app_user"("id") ON DELETE no action ON UPDATE no action;
15+
--> statement-breakpoint
16+
ALTER TABLE "app_admin_impersonation_event"
17+
ADD CONSTRAINT "app_admin_impersonation_event_siren_app_company_siren_fk"
18+
FOREIGN KEY ("siren") REFERENCES "app_company"("siren") ON DELETE no action ON UPDATE no action;
19+
--> statement-breakpoint
20+
CREATE INDEX IF NOT EXISTS "admin_impersonation_event_admin_started_idx"
21+
ON "app_admin_impersonation_event" ("admin_user_id", "started_at");

packages/app/drizzle/meta/_journal.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,13 @@
169169
"when": 1775500000000,
170170
"tag": "0023_add_audit_action_log",
171171
"breakpoints": true
172+
},
173+
{
174+
"idx": 24,
175+
"version": "7",
176+
"when": 1775600000000,
177+
"tag": "0024_add_admin_impersonation_events",
178+
"breakpoints": true
172179
}
173180
]
174181
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { ImpersonatePage } from "~/modules/admin";
2+
3+
export default function Page() {
4+
return <ImpersonatePage />;
5+
}

packages/app/src/app/layout.tsx

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,14 @@ import type { Metadata } from "next";
22
import Script from "next/script";
33

44
import { MatomoAnalytics } from "~/modules/analytics";
5-
import { Footer, Header, ResourceBanner, SkipLinks } from "~/modules/layout";
5+
import { SessionProviderWrapper } from "~/modules/auth";
6+
import {
7+
Footer,
8+
Header,
9+
ImpersonateBanner,
10+
ResourceBanner,
11+
SkipLinks,
12+
} from "~/modules/layout";
613
import { ProfileModal } from "~/modules/profile";
714
import { TRPCReactProvider } from "~/trpc/react";
815

@@ -47,13 +54,16 @@ export default function RootLayout({
4754
<body>
4855
<MatomoAnalytics />
4956
<SkipLinks />
50-
<TRPCReactProvider>
51-
<Header />
52-
{children}
53-
<ResourceBanner />
54-
<Footer />
55-
<ProfileModal />
56-
</TRPCReactProvider>
57+
<SessionProviderWrapper>
58+
<TRPCReactProvider>
59+
<ImpersonateBanner />
60+
<Header />
61+
{children}
62+
<ResourceBanner />
63+
<Footer />
64+
<ProfileModal />
65+
</TRPCReactProvider>
66+
</SessionProviderWrapper>
5767
<Script
5868
src="/dsfr/dsfr.module.min.js"
5969
strategy="afterInteractive"

packages/app/src/app/mon-espace/page.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,17 @@ export default async function Page() {
1111
redirect("/login");
1212
}
1313

14+
// When an admin is impersonating a company, use the impersonated SIREN
15+
// (padded to SIRET length so MonEspacePage can extract it the same way).
16+
const effectiveSiret =
17+
session.user.isAdmin && session.user.impersonation
18+
? session.user.impersonation.siren
19+
: (session.user.siret ?? null);
20+
1421
return (
1522
<HydrateClient>
1623
<MonEspacePage
17-
siret={session.user.siret ?? null}
24+
siret={effectiveSiret}
1825
userPhone={session.user.phone ?? null}
1926
/>
2027
</HydrateClient>

packages/app/src/e2e/admin.e2e.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,12 @@ test("admin user can access /admin and sees backoffice page", async ({
99
).toBeVisible();
1010
await expect(page.getByText("administrateur")).toBeVisible();
1111
});
12+
13+
test("admin user can access /admin/impersonate and sees impersonate page", async ({
14+
page,
15+
}) => {
16+
await page.goto("/admin/impersonate");
17+
await expect(
18+
page.getByRole("heading", { name: "Mimoquer une entreprise", level: 1 }),
19+
).toBeVisible();
20+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { impersonateSearchSchema, startImpersonateSchema } from "../schemas";
4+
5+
describe("admin schemas", () => {
6+
it("accepts a valid 9-digit SIREN", () => {
7+
expect(
8+
impersonateSearchSchema.safeParse({ siren: "123456789" }).success,
9+
).toBe(true);
10+
expect(
11+
startImpersonateSchema.safeParse({ siren: "987654321" }).success,
12+
).toBe(true);
13+
});
14+
15+
it("strips spaces before validating (e.g. '775 670 417')", () => {
16+
const result = impersonateSearchSchema.safeParse({
17+
siren: "775 670 417",
18+
});
19+
expect(result.success).toBe(true);
20+
if (result.success) {
21+
expect(result.data.siren).toBe("775670417");
22+
}
23+
});
24+
25+
it.each([
26+
"",
27+
"12345678",
28+
"1234567890",
29+
"12345678a",
30+
"abcdefghi",
31+
])("rejects invalid SIREN %s", (siren) => {
32+
expect(impersonateSearchSchema.safeParse({ siren }).success).toBe(false);
33+
});
34+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
type Props = {
2+
company: {
3+
siren: string;
4+
name: string;
5+
address: string | null;
6+
nafCode: string | null;
7+
workforce: number | null;
8+
};
9+
};
10+
11+
/**
12+
* Read-only summary of an entreprise, shown before the admin confirms they
13+
* want to impersonate it.
14+
*/
15+
export function CompanyPreviewCard({ company }: Props) {
16+
return (
17+
<div className="fr-card fr-mt-3w">
18+
<div className="fr-card__body">
19+
<div className="fr-card__content">
20+
<h2 className="fr-card__title fr-h3">{company.name}</h2>
21+
<div className="fr-card__desc">
22+
<p>
23+
<strong>SIREN :</strong> {company.siren}
24+
</p>
25+
{company.address && (
26+
<p>
27+
<strong>Adresse :</strong> {company.address}
28+
</p>
29+
)}
30+
{company.nafCode && (
31+
<p>
32+
<strong>Code NAF :</strong> {company.nafCode}
33+
</p>
34+
)}
35+
{company.workforce !== null && (
36+
<p>
37+
<strong>Effectif :</strong> {company.workforce}
38+
</p>
39+
)}
40+
</div>
41+
</div>
42+
</div>
43+
</div>
44+
);
45+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"use client";
2+
3+
import { useRouter } from "next/navigation";
4+
import { useSession } from "next-auth/react";
5+
import { useState } from "react";
6+
7+
import { impersonateSearchSchema } from "~/modules/admin/schemas";
8+
import { useZodForm } from "~/modules/shared/useZodForm";
9+
import { api } from "~/trpc/react";
10+
11+
import { CompanyPreviewCard } from "./CompanyPreviewCard";
12+
13+
/**
14+
* Search + preview + start/stop impersonation.
15+
*
16+
* The impersonation state is stored in the NextAuth JWT. Starting is a
17+
* single `session.update({ impersonation: { siren, name } })` call: the
18+
* server-side `jwt` callback writes the audit row and mutates the token
19+
* atomically. Stopping is the same call with `null`.
20+
*/
21+
export function ImpersonateForm() {
22+
const session = useSession();
23+
const router = useRouter();
24+
const [serverError, setServerError] = useState<string | null>(null);
25+
const [starting, setStarting] = useState(false);
26+
27+
const form = useZodForm(impersonateSearchSchema, {
28+
defaultValues: { siren: "" },
29+
});
30+
31+
const lastImpersonated = api.admin.getLastImpersonated.useQuery(undefined, {
32+
enabled: session.data?.user.isAdmin === true,
33+
});
34+
35+
const searchMutation = api.admin.searchCompany.useMutation({
36+
onSuccess: () => setServerError(null),
37+
onError: (err) => setServerError(err.message),
38+
});
39+
40+
const preview = searchMutation.data ?? null;
41+
42+
const onSearch = form.handleSubmit((values) => {
43+
searchMutation.mutate(values);
44+
});
45+
46+
const onStart = async () => {
47+
if (!preview) return;
48+
setStarting(true);
49+
try {
50+
await session.update({
51+
impersonation: { siren: preview.siren, name: preview.name },
52+
});
53+
router.push("/mon-espace");
54+
} finally {
55+
setStarting(false);
56+
}
57+
};
58+
59+
const sirenError = form.formState.errors.siren?.message;
60+
61+
return (
62+
<div className="fr-mt-4w">
63+
<form noValidate onSubmit={onSearch}>
64+
<div
65+
className={
66+
sirenError
67+
? "fr-input-group fr-input-group--error"
68+
: "fr-input-group"
69+
}
70+
>
71+
<label className="fr-label" htmlFor="impersonate-siren">
72+
SIREN de l'entreprise
73+
</label>
74+
<input
75+
aria-describedby={
76+
sirenError ? "impersonate-siren-error" : undefined
77+
}
78+
aria-invalid={Boolean(sirenError)}
79+
aria-required="true"
80+
autoComplete="off"
81+
className="fr-input"
82+
id="impersonate-siren"
83+
inputMode="numeric"
84+
list="impersonate-siren-list"
85+
type="text"
86+
{...form.register("siren")}
87+
/>
88+
<datalist id="impersonate-siren-list">
89+
{(lastImpersonated.data ?? []).map((c) => (
90+
<option key={c.siren} value={c.siren}>
91+
{c.name}
92+
</option>
93+
))}
94+
</datalist>
95+
{sirenError && (
96+
<p className="fr-error-text" id="impersonate-siren-error">
97+
{sirenError}
98+
</p>
99+
)}
100+
</div>
101+
102+
<ul className="fr-btns-group fr-btns-group--inline-sm">
103+
<li>
104+
<button
105+
className="fr-btn"
106+
disabled={searchMutation.isPending}
107+
type="submit"
108+
>
109+
Rechercher
110+
</button>
111+
</li>
112+
</ul>
113+
</form>
114+
115+
{serverError && (
116+
<div className="fr-alert fr-alert--error fr-mt-2w" role="alert">
117+
<h3 className="fr-alert__title">Erreur</h3>
118+
<p>{serverError}</p>
119+
</div>
120+
)}
121+
122+
{preview && (
123+
<>
124+
<CompanyPreviewCard company={preview} />
125+
<ul className="fr-btns-group fr-btns-group--inline-sm fr-mt-2w">
126+
<li>
127+
<button
128+
className="fr-btn"
129+
disabled={starting}
130+
onClick={onStart}
131+
type="button"
132+
>
133+
Valider et mimoquer
134+
</button>
135+
</li>
136+
</ul>
137+
</>
138+
)}
139+
</div>
140+
);
141+
}

0 commit comments

Comments
 (0)