feat(10-04): AdminPage + /admin route + conditional nav entries + e2e spec

- AdminPage: Admin Settings heading, MEMBERS section (avatar+status+action), SHARED CALENDAR radio group + two-tap Save + empty state
- App.tsx: /admin route gated by meQuery.data.user.isAdmin (loading gate prevents flash), SetupBanner mounted above content, BottomTabBar + AppNav receive isAdmin
- AppNav.tsx: ShieldCheck Admin nav entry rendered only when isAdmin=true (D-03 UX gating)
- BottomTabBar.tsx: ShieldCheck Admin tab rendered only when isAdmin=true (D-03 UX gating)
- e2e/admin.spec.ts: 5 assertions across 3 profiles (15 total tests) — admin sees nav+page+members, non-admin: no nav entry + /admin redirects to /calendar
- All 15 e2e tests pass (iphone/pixel/desktop); production build clean
This commit is contained in:
Lucas Berger
2026-06-13 15:16:11 -04:00
parent 2c2c71e7cc
commit 7808426a2f
5 changed files with 717 additions and 4 deletions
+14 -1
View File
@@ -12,7 +12,7 @@
*/
import { NavLink } from 'react-router';
import { CalendarDays, List } from 'lucide-react';
import { CalendarDays, List, ShieldCheck } from 'lucide-react';
import { ColorLegend, type LegendMember } from './ColorLegend.js';
interface AppNavProps {
@@ -21,6 +21,8 @@ interface AppNavProps {
currentUserName?: string;
/** Called when the user avatar is tapped — opens the Settings sheet. */
onOpenSettings?: () => void;
/** When true, renders the Admin nav entry (ShieldCheck). UX gating only (D-03). */
isAdmin?: boolean;
}
export function AppNav({
@@ -28,6 +30,7 @@ export function AppNav({
currentUserColor,
currentUserName,
onOpenSettings,
isAdmin = false,
}: AppNavProps) {
const isMobile = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
@@ -47,6 +50,7 @@ export function AppNav({
currentUserColor={currentUserColor}
currentUserName={currentUserName}
onOpenSettings={onOpenSettings}
isAdmin={isAdmin}
/>
);
}
@@ -129,11 +133,13 @@ function DesktopNav({
currentUserColor,
currentUserName,
onOpenSettings,
isAdmin = false,
}: {
members: LegendMember[];
currentUserColor?: string;
currentUserName?: string;
onOpenSettings?: () => void;
isAdmin?: boolean;
}) {
const navLinkStyle = ({ isActive }: { isActive: boolean }): React.CSSProperties => ({
display: 'flex',
@@ -198,6 +204,13 @@ function DesktopNav({
<List size={18} aria-hidden="true" />
Lists
</NavLink>
{/* Admin entry — only when isAdmin=true (UX gating, D-03) */}
{isAdmin && (
<NavLink to="/admin" style={navLinkStyle} aria-label="Admin settings">
<ShieldCheck size={18} aria-hidden="true" />
Admin
</NavLink>
)}
</div>
{/* Color legend */}
+22 -2
View File
@@ -18,7 +18,7 @@
*/
import { NavLink } from 'react-router';
import { CalendarDays, List } from 'lucide-react';
import { CalendarDays, List, ShieldCheck } from 'lucide-react';
function isPhone(): boolean {
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
@@ -49,7 +49,12 @@ const tabActiveOverride: React.CSSProperties = {
borderBottom: '2px solid var(--color-member-0)',
};
export function BottomTabBar() {
interface BottomTabBarProps {
/** When true, renders the Admin tab (ShieldCheck). UX gating only (D-03). */
isAdmin?: boolean;
}
export function BottomTabBar({ isAdmin = false }: BottomTabBarProps) {
// Phone-only: return null on desktop (≥768px) so the fixed bar does not overlay
// the AppNav sidebar's Settings/avatar button (FIX 4). Consistent with the
// isPhone() breakpoint used in AppNav and CalendarShell.
@@ -96,6 +101,21 @@ export function BottomTabBar() {
<List size={22} aria-hidden="true" />
<span>Lists</span>
</NavLink>
{/* Admin tab — only when isAdmin=true (UX gating, D-03) */}
{isAdmin && (
<NavLink
to="/admin"
aria-label="Admin settings"
style={({ isActive }) => ({
...tabBase,
...(isActive ? tabActiveOverride : {}),
})}
>
<ShieldCheck size={22} aria-hidden="true" />
<span>Admin</span>
</NavLink>
)}
</nav>
);
}