Skip to content

Commit e23f86c

Browse files
feat(admin): add sidemenu navigation to backoffice (#3195)
1 parent d57f63b commit e23f86c

11 files changed

Lines changed: 166 additions & 5 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- Add isAdmin flag on user table for backoffice (admin) access control
2+
ALTER TABLE "app_user" ADD COLUMN IF NOT EXISTS "is_admin" boolean DEFAULT false NOT NULL;

packages/app/drizzle/meta/_journal.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,13 @@
176176
"when": 1775600000000,
177177
"tag": "0024_add_admin_impersonation_events",
178178
"breakpoints": true
179+
},
180+
{
181+
"idx": 25,
182+
"version": "7",
183+
"when": 1775700000000,
184+
"tag": "0025_add_user_is_admin",
185+
"breakpoints": true
179186
}
180187
]
181188
}

packages/app/src/app/admin/layout.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { redirect } from "next/navigation";
22
import type { ReactNode } from "react";
33

4+
import { AdminNavigation } from "~/modules/admin";
45
import { auth } from "~/server/auth";
56

67
export default async function AdminLayout({
@@ -18,5 +19,14 @@ export default async function AdminLayout({
1819
redirect("/mon-espace");
1920
}
2021

21-
return <>{children}</>;
22+
return (
23+
<div className="fr-container fr-py-6w">
24+
<div className="fr-grid-row fr-grid-row--gutters">
25+
<div className="fr-col-12 fr-col-md-4">
26+
<AdminNavigation />
27+
</div>
28+
<div className="fr-col-12 fr-col-md-8">{children}</div>
29+
</div>
30+
</div>
31+
);
2232
}

packages/app/src/env.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,9 @@ export const env = createEnv({
6161
EGAPRO_SUIT_PUBLIC_KEY_PEM: z.string().optional(),
6262
/**
6363
* Comma-separated list of emails that should be granted the admin role
64-
* on login. The flag is resolved in the NextAuth JWT callback.
64+
* on login. The flag is then persisted in the `app_user.is_admin` column.
6565
*/
66-
ADMIN_EMAILS: z.string().min(1),
66+
ADMIN_EMAILS: z.string().optional().default(""),
6767
// Audit log (issue #3174) — bearer token for the cleanup cron + retention
6868
// thresholds (CNIL: 6 months for access logs, 12 months for security logs).
6969
// Required (not optional): the /api/audit/cleanup route destroys data, so

packages/app/src/modules/admin/AdminHomePage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@ type Props = {
55

66
export function AdminHomePage({ userName, userEmail }: Props) {
77
return (
8-
<div className="fr-container fr-py-6w">
8+
<>
99
<h1 className="fr-h1">Backoffice</h1>
1010
<p className="fr-text--lead">Bienvenue, {userName}.</p>
1111
<p>
1212
Vous êtes connecté en tant qu'administrateur avec l'adresse{" "}
1313
<strong>{userEmail}</strong>.
1414
</p>
15-
</div>
15+
</>
1616
);
1717
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"use client";
2+
3+
import Link from "next/link";
4+
import { usePathname } from "next/navigation";
5+
6+
const adminLinks = [
7+
{ href: "/admin", label: "Accueil" },
8+
{ href: "/admin/impersonate", label: "Mimoquer un Siren" },
9+
] as const;
10+
11+
export function AdminNavigation() {
12+
const pathname = usePathname();
13+
14+
return (
15+
<nav aria-labelledby="fr-sidemenu-title" className="fr-sidemenu">
16+
<div className="fr-sidemenu__inner">
17+
<button
18+
aria-controls="fr-sidemenu-wrapper"
19+
aria-expanded="false"
20+
className="fr-sidemenu__btn"
21+
type="button"
22+
>
23+
Administration
24+
</button>
25+
<div className="fr-collapse" id="fr-sidemenu-wrapper">
26+
<div className="fr-sidemenu__title" id="fr-sidemenu-title">
27+
Administration
28+
</div>
29+
<ul className="fr-sidemenu__list">
30+
{adminLinks.map(({ href, label }) => {
31+
const isActive =
32+
href === "/admin"
33+
? pathname === "/admin"
34+
: pathname.startsWith(href);
35+
36+
const itemClass = isActive
37+
? "fr-sidemenu__item fr-sidemenu__item--active"
38+
: "fr-sidemenu__item";
39+
40+
return (
41+
<li className={itemClass} key={href}>
42+
<Link
43+
aria-current={isActive ? "page" : undefined}
44+
className="fr-sidemenu__link"
45+
href={href}
46+
>
47+
{label}
48+
</Link>
49+
</li>
50+
);
51+
})}
52+
</ul>
53+
</div>
54+
</div>
55+
</nav>
56+
);
57+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { usePathname } from "next/navigation";
3+
import { beforeEach, describe, expect, it, type Mock } from "vitest";
4+
5+
import { AdminNavigation } from "../AdminNavigation";
6+
7+
describe("AdminNavigation", () => {
8+
beforeEach(() => {
9+
(usePathname as Mock).mockReturnValue("/admin");
10+
});
11+
12+
it("renders a sidemenu nav with the admin title", () => {
13+
render(<AdminNavigation />);
14+
expect(
15+
screen.getByRole("navigation", { name: "Administration" }),
16+
).toBeInTheDocument();
17+
});
18+
19+
it("renders all admin links", () => {
20+
render(<AdminNavigation />);
21+
expect(screen.getByRole("link", { name: "Accueil" })).toBeInTheDocument();
22+
expect(
23+
screen.getByRole("link", { name: "Mimoquer un Siren" }),
24+
).toBeInTheDocument();
25+
});
26+
27+
it("marks /admin as active when on /admin", () => {
28+
(usePathname as Mock).mockReturnValue("/admin");
29+
render(<AdminNavigation />);
30+
const activeLink = screen.getByRole("link", { name: "Accueil" });
31+
expect(activeLink).toHaveAttribute("aria-current", "page");
32+
expect(activeLink.closest("li")).toHaveClass(
33+
"fr-sidemenu__item",
34+
"fr-sidemenu__item--active",
35+
);
36+
expect(
37+
screen.getByRole("link", { name: "Mimoquer un Siren" }),
38+
).not.toHaveAttribute("aria-current");
39+
});
40+
41+
it("marks /admin/impersonate as active when on that page", () => {
42+
(usePathname as Mock).mockReturnValue("/admin/impersonate");
43+
render(<AdminNavigation />);
44+
expect(
45+
screen.getByRole("link", { name: "Mimoquer un Siren" }),
46+
).toHaveAttribute("aria-current", "page");
47+
expect(screen.getByRole("link", { name: "Accueil" })).not.toHaveAttribute(
48+
"aria-current",
49+
);
50+
});
51+
});

packages/app/src/modules/admin/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { AdminHomePage } from "./AdminHomePage";
2+
export { AdminNavigation } from "./AdminNavigation";
23
export { ImpersonatePage } from "./impersonate/ImpersonatePage";
34
export {
45
type ImpersonateSearchInput,

packages/app/src/modules/layout/Header/HeaderQuickAccessLinks.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export function HeaderQuickAccessLinks({ session }: Props) {
3030
<li>
3131
{session?.user ? (
3232
<UserAccountMenu
33+
isAdmin={session.user.isAdmin}
3334
userEmail={session.user.email ?? ""}
3435
userName={session.user.name ?? "Utilisateur"}
3536
userPhone={session.user.phone ?? undefined}

packages/app/src/modules/layout/Header/UserAccountMenu.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@ interface UserAccountMenuProps {
1010
userName: string;
1111
userEmail: string;
1212
userPhone?: string;
13+
isAdmin?: boolean;
1314
}
1415

1516
/** Dropdown menu in the header for authenticated users. */
1617
export function UserAccountMenu({
1718
userName,
1819
userEmail,
1920
userPhone,
21+
isAdmin,
2022
}: UserAccountMenuProps) {
2123
const [isOpen, setIsOpen] = useState(false);
2224
const wrapperRef = useRef<HTMLDivElement>(null);
@@ -121,6 +123,16 @@ export function UserAccountMenu({
121123
</div>
122124

123125
<div className={styles.links}>
126+
{isAdmin && (
127+
<Link
128+
className={styles.menuLink}
129+
href="/admin"
130+
onClick={close}
131+
role="menuitem"
132+
>
133+
Administration
134+
</Link>
135+
)}
124136
<Link
125137
className={styles.menuLink}
126138
href="/mon-espace/mes-entreprises"

0 commit comments

Comments
 (0)