Skip to content

Commit 97d644f

Browse files
fix(mimoquage): refresh phone, skip missing-info modal, restore banner (#3253)
1 parent af8ad64 commit 97d644f

12 files changed

Lines changed: 211 additions & 35 deletions

File tree

packages/app/src/app/avis-cse/layout.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { redirect } from "next/navigation";
22
import { CseOpinionLayout } from "~/modules/cseOpinion";
3-
import { extractSiren } from "~/modules/my-space";
43
import { auth } from "~/server/auth";
4+
import { getEffectiveSiren } from "~/server/auth/companyAccess";
55
import { api } from "~/trpc/server";
66

77
export default async function CseOpinionRootLayout({
@@ -15,12 +15,11 @@ export default async function CseOpinionRootLayout({
1515
redirect("/login");
1616
}
1717

18-
const siret = session.user.siret;
19-
if (!siret) {
18+
const siren = getEffectiveSiren(session);
19+
if (!siren) {
2020
redirect("/");
2121
}
2222

23-
const siren = extractSiren(siret);
2423
const [company, declarationData] = await Promise.all([
2524
api.company.get({ siren }),
2625
api.declaration.getOrCreate(),

packages/app/src/app/declaration-remuneration/layout.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import {
33
DeclarationLayout,
44
MissingSiret,
55
} from "~/modules/declaration-remuneration";
6-
import { extractSiren } from "~/modules/domain";
76
import { auth } from "~/server/auth";
7+
import { getEffectiveSiren } from "~/server/auth/companyAccess";
88
import { api } from "~/trpc/server";
99

1010
export default async function DeclarationRootLayout({
@@ -18,12 +18,11 @@ export default async function DeclarationRootLayout({
1818
redirect("/login");
1919
}
2020

21-
const siret = session.user.siret;
22-
if (!siret) {
21+
const siren = getEffectiveSiren(session);
22+
if (!siren) {
2323
return <MissingSiret />;
2424
}
2525

26-
const siren = extractSiren(siret);
2726
const [company, declarationData] = await Promise.all([
2827
api.company.get({ siren }),
2928
api.declaration.getOrCreate(),

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

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { redirect } from "next/navigation";
22

33
import { MonEspacePage } from "~/modules/my-space";
44
import { auth } from "~/server/auth";
5-
import { HydrateClient } from "~/trpc/server";
5+
import { getEffectiveSiren } from "~/server/auth/companyAccess";
6+
import { api, HydrateClient } from "~/trpc/server";
67

78
export default async function Page() {
89
const session = await auth();
@@ -11,18 +12,24 @@ export default async function Page() {
1112
redirect("/login");
1213
}
1314

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);
15+
// When an admin is impersonating a company, use the impersonated SIREN.
16+
// MonEspacePage expects a SIRET-length string for symmetric handling;
17+
// passing the SIREN (9 chars) is fine since it only reads the first 9.
18+
const effectiveSiret = getEffectiveSiren(session);
19+
20+
// The JWT only captures `phone` at sign-in, so `session.user.phone` goes
21+
// stale as soon as the user saves a phone through the missing-info modal
22+
// and re-triggers it on every render. Reading from the profile table on
23+
// each page load keeps the missing-info gate honest. Fall back to `null`
24+
// when the profile row is missing so the page still renders (the modal
25+
// will prompt for a phone number just like before).
26+
const profile = await api.profile.get().catch(() => null);
2027

2128
return (
2229
<HydrateClient>
2330
<MonEspacePage
2431
siret={effectiveSiret}
25-
userPhone={session.user.phone ?? null}
32+
userPhone={profile?.phone ?? null}
2633
/>
2734
</HydrateClient>
2835
);

packages/app/src/modules/my-space/DeclarationLink.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use client";
22

3+
import { useIsImpersonating } from "~/modules/auth";
34
import { hasRequiredDeclarationInfo } from "~/modules/domain";
45

56
import { DECLARATION_PROCESS_PANEL_ID } from "./DeclarationProcessPanel";
@@ -16,7 +17,9 @@ type Props = {
1617

1718
/** Link that opens the missing info modal if phone or CSE is missing, or navigates/opens panel directly. */
1819
export function DeclarationLink({ type, userPhone, hasCse, children }: Props) {
19-
const hasMissingInfo = !hasRequiredDeclarationInfo(userPhone, hasCse);
20+
const isImpersonating = useIsImpersonating();
21+
const hasMissingInfo =
22+
!isImpersonating && !hasRequiredDeclarationInfo(userPhone, hasCse);
2023

2124
// When info is missing, open missing-info modal (for both types)
2225
if (hasMissingInfo) {

packages/app/src/modules/my-space/MissingInfoModal.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
44

5+
import { useIsImpersonating } from "~/modules/auth";
56
import { getDsfrModal } from "~/modules/shared";
67
import { PhoneField } from "~/modules/shared/PhoneField";
78
import { useDsfrDialogOpen } from "~/modules/shared/useDsfrDialogOpen";
@@ -33,6 +34,7 @@ function getDescription(needsPhone: boolean, needsCse: boolean): string {
3334
type OpenerType = "remuneration" | "representation";
3435

3536
export function MissingInfoModal({ siren, userPhone, hasCse }: Props) {
37+
const isImpersonating = useIsImpersonating();
3638
const dialogRef = useRef<HTMLDialogElement>(null);
3739
const openerTypeRef = useRef<OpenerType>("remuneration");
3840
const needsPhone = !userPhone;
@@ -124,6 +126,12 @@ export function MissingInfoModal({ siren, userPhone, hasCse }: Props) {
124126
const isPending =
125127
updatePhoneMutation.isPending || updateHasCseMutation.isPending;
126128

129+
// Admin impersonation is read-only: every write would be blocked server-side
130+
// (issue #3230), so prompting for missing info is pure noise.
131+
if (isImpersonating) {
132+
return null;
133+
}
134+
127135
return (
128136
<dialog
129137
aria-labelledby={MODAL_TITLE_ID}

packages/app/src/modules/my-space/__tests__/DeclarationLink.test.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
import { render, screen } from "@testing-library/react";
2-
import { describe, expect, it } from "vitest";
2+
import { useSession } from "next-auth/react";
3+
import { afterEach, describe, expect, it, vi } from "vitest";
34

5+
import { mockImpersonatingSession } from "~/test/impersonationMock";
46
import { DeclarationLink } from "../DeclarationLink";
57

8+
const mockedUseSession = vi.mocked(useSession);
9+
610
describe("DeclarationLink", () => {
11+
afterEach(() => {
12+
mockedUseSession.mockReset();
13+
});
14+
715
it("renders remuneration as a button opening the process panel when info is present", () => {
816
render(
917
<DeclarationLink hasCse={true} type="remuneration" userPhone="0122334455">
@@ -63,4 +71,20 @@ describe("DeclarationLink", () => {
6371
const button = screen.getByRole("button", { name: "Représentation" });
6472
expect(button).toBeInTheDocument();
6573
});
74+
75+
it("bypasses missing info modal during admin impersonation", () => {
76+
mockImpersonatingSession(mockedUseSession);
77+
78+
render(
79+
<DeclarationLink hasCse={null} type="remuneration" userPhone={null}>
80+
Rémunération
81+
</DeclarationLink>,
82+
);
83+
84+
const button = screen.getByRole("button", { name: "Rémunération" });
85+
expect(button).toHaveAttribute(
86+
"aria-controls",
87+
"declaration-process-panel",
88+
);
89+
});
6690
});

packages/app/src/modules/my-space/__tests__/MissingInfoModal.test.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { render, screen } from "@testing-library/react";
2-
import { describe, expect, it, vi } from "vitest";
2+
import { useSession } from "next-auth/react";
3+
import { afterEach, describe, expect, it, vi } from "vitest";
4+
5+
import { mockImpersonatingSession } from "~/test/impersonationMock";
6+
7+
const mockedUseSession = vi.mocked(useSession);
38

49
vi.mock("~/trpc/react", () => ({
510
api: {
@@ -148,4 +153,19 @@ describe("MissingInfoModal", () => {
148153
screen.getByRole("button", { name: "Enregistrer", hidden: true }),
149154
).not.toBeDisabled();
150155
});
156+
157+
describe("admin impersonation", () => {
158+
afterEach(() => {
159+
mockedUseSession.mockReset();
160+
});
161+
162+
it("does not render the modal when impersonating", () => {
163+
mockImpersonatingSession(mockedUseSession);
164+
165+
const { container } = render(
166+
<MissingInfoModal hasCse={null} siren="532847196" userPhone={null} />,
167+
);
168+
expect(container.querySelector("#missing-info-modal")).toBeNull();
169+
});
170+
});
151171
});

packages/app/src/server/api/routers/__tests__/declaration.test.ts

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -722,26 +722,41 @@ describe("declarationRouter", () => {
722722
);
723723
});
724724

725-
it("refuses getOrCreate when no declaration exists (would create a draft)", async () => {
726-
// Simulate the "no existing row" branch — where getOrCreate would
727-
// normally insert a new draft. The impersonation guard fires right
728-
// before the insert, so the admin never creates a draft in the
729-
// user's name.
730-
const txSelect = vi.fn().mockImplementation(() => ({
731-
from: vi.fn().mockReturnValue({
732-
where: vi.fn().mockReturnValue({
733-
limit: vi.fn().mockResolvedValue([]),
725+
it("returns a placeholder declaration in mimoquage when none exists (no insert)", async () => {
726+
// When an admin mimoques a company that has not started a
727+
// declaration, getOrCreate must return a transient empty row so the
728+
// read-only UI can render. No INSERT is performed (issue #3230).
729+
const emptySelect = () =>
730+
vi.fn().mockImplementation(() => ({
731+
from: vi.fn().mockReturnValue({
732+
where: vi.fn().mockReturnValue({
733+
limit: vi.fn().mockResolvedValue([]),
734+
}),
734735
}),
735-
}),
736-
}));
737-
const tx = { select: txSelect, insert: vi.fn(), delete: vi.fn() };
736+
}));
737+
const insertSpy = vi.fn();
738+
const tx = {
739+
select: emptySelect(),
740+
insert: insertSpy,
741+
delete: vi.fn(),
742+
};
738743
mockTransaction.mockImplementation(async (fn: (tx: unknown) => unknown) =>
739744
fn(tx),
740745
);
741-
const mockDb = { transaction: mockTransaction } as unknown;
746+
const mockDb = {
747+
transaction: mockTransaction,
748+
select: emptySelect(),
749+
} as unknown;
742750
const caller = await createCaller(mockDb, null, impersonation);
743751

744-
await expect(caller.getOrCreate()).rejects.toThrow("Mode mimoquage");
752+
const result = await caller.getOrCreate();
753+
expect(result.declaration.id).toBe("");
754+
expect(result.declaration.siren).toBe(impersonation.siren);
755+
expect(result.declaration.status).toBe("draft");
756+
expect(result.declaration.currentStep).toBe(0);
757+
expect(result.jobCategories).toEqual([]);
758+
expect(result.employeeCategories).toEqual([]);
759+
expect(insertSpy).not.toHaveBeenCalled();
745760
});
746761

747762
it("allows getOrCreate when an existing declaration is returned", async () => {

packages/app/src/server/api/routers/declaration.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
} from "~/server/db/schema";
2525
import {
2626
buildEmployeeCategoryValues,
27+
buildPlaceholderDeclaration,
2728
deleteJobAndEmployeeCategories,
2829
fetchAllCategories,
2930
fetchPreviousYearJobCategories,
@@ -56,7 +57,20 @@ export const declarationRouter = createTRPCRouter({
5657

5758
// Admins impersonating a company can view an existing declaration in
5859
// read-only mode, but must not silently create a new draft in the
59-
// user's name (issue #3230).
60+
// user's name (issue #3230). When no row exists yet, return a
61+
// transient placeholder so the read-only UI can still render — it
62+
// is never persisted.
63+
if (ctx.session.user.isAdmin && ctx.session.user.impersonation) {
64+
return {
65+
declaration: buildPlaceholderDeclaration({
66+
siren,
67+
year,
68+
declarantId: ctx.session.user.id,
69+
}),
70+
jobCategories: [],
71+
employeeCategories: [],
72+
};
73+
}
6074
assertNotImpersonating(ctx.session);
6175

6276
const newDeclaration = await tx

packages/app/src/server/api/routers/declarationHelpers.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,50 @@
1-
import { and, desc, eq, lt } from "drizzle-orm";
1+
import { and, desc, eq, getTableColumns, lt } from "drizzle-orm";
22

33
import {
44
declarations,
55
employeeCategories,
66
jobCategories,
77
} from "~/server/db/schema";
88

9+
type DeclarationRow = typeof declarations.$inferSelect;
10+
11+
/**
12+
* Build a transient, zero-value declaration row used to satisfy the read-only
13+
* UI when an admin impersonates a company that has not started a declaration
14+
* yet (issue #3230). The row is never persisted — it only exists in memory
15+
* to let layouts and step pages render their empty state without
16+
* short-circuiting on a missing record.
17+
*
18+
* All columns default to `null`; the identifying / non-null-in-schema fields
19+
* (id, siren, year, declarantId, currentStep, status, timestamps) are
20+
* overridden explicitly so TypeScript enforces the shape.
21+
*/
22+
export function buildPlaceholderDeclaration({
23+
siren,
24+
year,
25+
declarantId,
26+
}: {
27+
siren: string;
28+
year: number;
29+
declarantId: string;
30+
}): DeclarationRow {
31+
const allNull = Object.fromEntries(
32+
Object.keys(getTableColumns(declarations)).map((key) => [key, null]),
33+
);
34+
const now = new Date();
35+
return {
36+
...allNull,
37+
id: "",
38+
siren,
39+
year,
40+
declarantId,
41+
currentStep: 0,
42+
status: "draft",
43+
createdAt: now,
44+
updatedAt: now,
45+
} as DeclarationRow;
46+
}
47+
948
export type Tx = Parameters<
1049
Parameters<import("~/server/db").DB["transaction"]>[0]
1150
>[0];

0 commit comments

Comments
 (0)