Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions frontend/src/RespondentLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ import type { ReactNode } from 'react';
export function RespondentLayout({ children }: { children: ReactNode }) {
return (
<div className="min-h-screen">
<header className="border-b border-border bg-surface px-6 py-3">
<header className="border-b border-border bg-surface px-4 py-3 sm:px-6">
<span className="text-base font-semibold text-brand-dark">Stele</span>
</header>
<main className="mx-auto max-w-2xl px-4 py-10">{children}</main>
<main className="mx-auto max-w-2xl px-4 py-6 sm:py-10">{children}</main>
</div>
);
}
44 changes: 44 additions & 0 deletions frontend/src/admin/AdminLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,48 @@ describe('AdminLayout', () => {
expect(screen.queryByRole('link', { name: 'DB Credentials' })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'PII Review' })).not.toBeInTheDocument();
});

// The desktop nav doesn't fit on small viewports alongside the user/logout
// block, so we collapse it behind a hamburger button (issue #48). jsdom
// doesn't apply Tailwind's responsive CSS, so we can't assert visibility, but
// we can assert the collapsed-menu DOM contract: button toggles the drawer
// and the drawer mirrors the nav + logout.
it('exposes a hamburger that opens a mirror nav drawer', async () => {
asRole('admin');
renderLayout();
await screen.findByTestId('current-user');

const toggle = screen.getByRole('button', { name: 'Open menu' });
expect(toggle).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByTestId('current-user-mobile')).not.toBeInTheDocument();

await userEvent.click(toggle);

expect(toggle).toHaveAttribute('aria-expanded', 'true');
expect(toggle).toHaveAccessibleName('Close menu');
// Drawer renders its own user block and a second copy of every nav link.
expect(screen.getByTestId('current-user-mobile')).toBeInTheDocument();
expect(screen.getAllByRole('link', { name: 'Surveys' })).toHaveLength(2);
expect(screen.getAllByRole('button', { name: 'Log out' })).toHaveLength(2);
});

it('closes the drawer when a nav link is clicked', async () => {
asRole('admin');
renderLayout();
await screen.findByTestId('current-user');

await userEvent.click(screen.getByRole('button', { name: 'Open menu' }));
expect(screen.getByTestId('current-user-mobile')).toBeInTheDocument();

// Click the drawer copy of the Surveys link. Both copies route to the same
// place; either close path is fine, but tapping a link is the common one.
const surveysLinks = screen.getAllByRole('link', { name: 'Surveys' });
await userEvent.click(surveysLinks[surveysLinks.length - 1]!);

expect(screen.queryByTestId('current-user-mobile')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Open menu' })).toHaveAttribute(
'aria-expanded',
'false',
);
});
});
185 changes: 130 additions & 55 deletions frontend/src/admin/AdminLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,86 +1,161 @@
import { useState } from 'react';
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom';

import { useAuth } from '../auth/AuthContext';
import { Button } from '../ui';

/** Shell for the authenticated admin area: a branded header with role-aware
* navigation, the current user, and a logout control, plus the routed view. */
* navigation, the current user, and a logout control, plus the routed view.
*
* Below `lg` (1024px) the full nav can't fit alongside the user/logout block
* for an admin, so we collapse it behind a hamburger that opens a stacked
* drawer underneath the header. Above `lg` the drawer never renders. */
export function AdminLayout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [menuOpen, setMenuOpen] = useState(false);

const handleLogout = (): void => {
void logout().then(() => navigate('/admin/login', { replace: true }));
};

const closeMenu = (): void => setMenuOpen(false);

const navLinkClass = ({ isActive }: { isActive: boolean }): string =>
[
'rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
isActive ? 'bg-brand-light text-brand-dark' : 'text-muted hover:bg-canvas hover:text-ink',
].join(' ');

// Same link set is rendered in the desktop top-bar nav and the mobile drawer;
// the container controls direction/visibility, the links themselves don't care.
const navLinks = user ? (
<>
{/* Authors see the survey workspace; admins the GDPR console; reviewers
the PII screening queue. Role drives which links appear (design §3.10). */}
{user.roles.includes('researcher') || user.roles.includes('admin') ? (
<NavLink to="/admin" end className={navLinkClass} onClick={closeMenu}>
Surveys
</NavLink>
) : null}
{user.roles.includes('admin') ? (
<NavLink to="/admin/etl" className={navLinkClass} onClick={closeMenu}>
ETL
</NavLink>
) : null}
{user.roles.includes('admin') ? (
<NavLink to="/admin/gdpr" className={navLinkClass} onClick={closeMenu}>
GDPR
</NavLink>
) : null}
{user.roles.includes('admin') ? (
<NavLink to="/admin/users" className={navLinkClass} onClick={closeMenu}>
Users
</NavLink>
) : null}
{user.roles.includes('admin') ? (
<NavLink to="/admin/db-credentials" className={navLinkClass} onClick={closeMenu}>
DB Credentials
</NavLink>
) : null}
{user.roles.includes('reviewer') ? (
<NavLink to="/admin/pii-review" className={navLinkClass} onClick={closeMenu}>
PII Review
</NavLink>
) : null}
{/* Anyone may hold a DB credential to reveal/regenerate (§3.10). */}
<NavLink to="/admin/my-access" className={navLinkClass} onClick={closeMenu}>
My DB access
</NavLink>
</>
) : null;

return (
<div className="min-h-screen">
<header className="flex items-center justify-between border-b border-border bg-surface px-6 py-3">
<div className="flex items-center gap-6">
<Link to="/admin" className="text-base font-semibold text-brand-dark">
Stele
</Link>
<nav className="flex items-center gap-1">
{/* Authors see the survey workspace; admins the GDPR console; reviewers
the PII screening queue. Role drives which links appear (design §3.10). */}
{user && (user.roles.includes('researcher') || user.roles.includes('admin')) ? (
<NavLink to="/admin" end className={navLinkClass}>
Surveys
</NavLink>
) : null}
{user?.roles.includes('admin') ? (
<NavLink to="/admin/etl" className={navLinkClass}>
ETL
</NavLink>
) : null}
{user?.roles.includes('admin') ? (
<NavLink to="/admin/gdpr" className={navLinkClass}>
GDPR
</NavLink>
) : null}
{user?.roles.includes('admin') ? (
<NavLink to="/admin/users" className={navLinkClass}>
Users
</NavLink>
) : null}
{user?.roles.includes('admin') ? (
<NavLink to="/admin/db-credentials" className={navLinkClass}>
DB Credentials
</NavLink>
) : null}
{user?.roles.includes('reviewer') ? (
<NavLink to="/admin/pii-review" className={navLinkClass}>
PII Review
</NavLink>
) : null}
{/* Anyone may hold a DB credential to reveal/regenerate (§3.10). */}
{user ? (
<NavLink to="/admin/my-access" className={navLinkClass}>
My DB access
</NavLink>
) : null}
</nav>
<header className="border-b border-border bg-surface">
<div className="flex items-center justify-between gap-3 px-4 py-3 sm:px-6">
<div className="flex items-center gap-6">
<Link to="/admin" className="text-base font-semibold text-brand-dark">
Stele
</Link>
<nav className="hidden items-center gap-1 lg:flex">{navLinks}</nav>
</div>
{user ? (
<>
<div className="hidden items-center gap-3 lg:flex">
<span data-testid="current-user" className="text-sm text-muted">
{user.email} ({user.roles.join(', ')})
</span>
<Button type="button" variant="secondary" size="sm" onClick={handleLogout}>
Log out
</Button>
</div>
<button
type="button"
aria-label={menuOpen ? 'Close menu' : 'Open menu'}
aria-expanded={menuOpen}
aria-controls="admin-mobile-menu"
onClick={() => setMenuOpen((open) => !open)}
className="-mr-1 rounded-md p-2 text-ink hover:bg-canvas lg:hidden"
>
<MenuIcon open={menuOpen} />
</button>
</>
) : null}
</div>
{user ? (
<div className="flex items-center gap-3">
<span data-testid="current-user" className="text-sm text-muted">
{user.email} ({user.roles.join(', ')})
</span>
<Button type="button" variant="secondary" size="sm" onClick={handleLogout}>
Log out
</Button>
{user && menuOpen ? (
<div
id="admin-mobile-menu"
className="border-t border-border bg-surface px-4 pb-3 pt-2 lg:hidden"
>
<nav className="flex flex-col gap-1">{navLinks}</nav>
<div className="mt-3 flex items-center justify-between gap-3 border-t border-border pt-3">
<span
data-testid="current-user-mobile"
className="truncate text-sm text-muted"
title={`${user.email} (${user.roles.join(', ')})`}
>
{user.email} ({user.roles.join(', ')})
</span>
<Button type="button" variant="secondary" size="sm" onClick={handleLogout}>
Log out
</Button>
</div>
</div>
) : null}
</header>
<main className="mx-auto max-w-5xl px-6 py-8">
<main className="mx-auto max-w-5xl px-4 py-6 sm:px-6 sm:py-8">
<Outlet />
</main>
</div>
);
}

function MenuIcon({ open }: { open: boolean }) {
return (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{open ? (
<>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</>
) : (
<>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</>
)}
</svg>
);
}
Loading
Loading