Files
EpicNext-Cms/src/components/mobile-nav.tsx
T
openhands 6cc45d7413
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m42s
CI / tests-unit (push) Failing after 1m49s
CI / tests-ui (push) Successful in 2m31s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
feat: harden atoms-nexst against review findings (37 items)
Second review pass covering security, performance, admin tooling and the
public/room flows. All HIGH and MEDIUM findings from the audit are resolved;
nothing in this commit changes the visible feature set.

Authentication & session security
- CSP is now set on the request headers in the proxy, which is what Next.js
  uses to derive the render nonce, so the nonce is effective.
- 2FA: an already-enabled user cannot re-enroll, the setup endpoint is
  rate-limited per account, and confirmed codes are persisted so the second
  secret no longer silently never applies.
- Password reset revokes the ticket, authTicket and all personal access
  tokens, and bumps the token version so existing sessions die. The same
  revocation is now wired into the staff-side password reset.
- /reset and /verify return a stable error code instead of raw text; the
  mail lookups are ordered by id so duplicates cannot vary between runs.
- Resending the verification mail gets a per-address cooldown on top of the
  per-user limit.
- Issue API tokens with the narrower radio/ticket ability set instead of "*".

Authorization & input handling
- Mid-rank staff can no longer keep dynamically granted non-view admin.*
  permissions: existing grants are revoked by migration and the grant lookup
  is restricted to "%.view". Rank guards use the dynamic super-admin check.
- Alerting a user is permission-checked and audited like the other tools.
- Material mutations (giveCredits/giveDuckets/giveDiamonds, the admin user
  actions route, bulk user actions) are capped and rank-guarded, and bulk
  ids are bounded.
- updateRoom / updateRoomItem write through a field allowlist, and items
  may only be edited through their own room.
- Classnames reaching the filesystem are validated before use so a crafted
  value cannot escape the asset directories.
- The word filter now also covers offline mails, guild forum threads and
  replies, and user mottos.
- Media uploads are validated by magic bytes, /api/media requires the page
  edit permission, APP_URL must be configured once mail is enabled, and the
  diagnostics error route checks the fetch site header.

Admin tooling
- Secret settings render masked and cannot be overwritten with a blank or
  an arbitrary raw key; radio credentials are new password inputs.
- Commandocentrum balance changes are audited.
- Admin list pagination reads the caller's per-page instead of the max, and
  the log exporter caps offset and search length.

Performance
- Catalog translations are cached per module, with a cheap revision hash;
  the public online count uses a stale window instead of hammering the DB.
- The cache warmup now primes the payload the home route actually reads.
- TopHeader batches its queries into one round trip, and LCP avatars load
  eagerly.
- motion/react and sonner are no longer part of the root layout; the nav
  dropdown and mobile nav panels are lazy client chunks. Anonymous visitors
  again get the navigation chrome, and public pages get an edge cacheable
  response.

Accessibility
- Nested <main> elements in phase pages became <section>; the page entrance
  and route progress animations are pure CSS that respect reduced motion.
2026-10-09 16:19:48 +02:00

173 lines
4.5 KiB
TypeScript

"use client";
import dynamic from "next/dynamic";
import {
type ReactNode,
useCallback,
useEffect,
useId,
useRef,
useState,
} from "react";
/** Lazily loaded so `motion/react` is not in the public pages' initial JS. */
const MobileNavPanel = dynamic(() => import("@/components/mobile-nav-panel"), {
ssr: false,
});
interface MobileNavProps {
children: ReactNode;
menuLabel?: string;
closeLabel?: string;
brandLabel?: string;
}
export function MobileNav({
children,
menuLabel = "Open menu",
closeLabel = "Close menu",
brandLabel = "",
}: MobileNavProps) {
const [open, setOpen] = useState(false);
const detailsRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const prevFocusRef = useRef<HTMLElement | null>(null);
const menuId = useId();
const handleEscape = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
setOpen(false);
(prevFocusRef.current ?? toggleRef.current)?.focus();
}
},
[open],
);
useEffect(() => {
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [handleEscape]);
// Initial focus, scroll-lock, click-outside close, focus restore.
useEffect(() => {
if (!open) return;
prevFocusRef.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
menuRef.current
?.querySelector<HTMLElement>(
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])',
)
?.focus();
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
function handlePointerDown(e: PointerEvent) {
if (
menuRef.current &&
!menuRef.current.contains(e.target as Node) &&
!toggleRef.current?.contains(e.target as Node)
) {
setOpen(false);
}
}
document.addEventListener("pointerdown", handlePointerDown);
return () => {
document.body.style.overflow = prevOverflow;
document.removeEventListener("pointerdown", handlePointerDown);
(prevFocusRef.current ?? toggleRef.current)?.focus();
};
}, [open]);
useEffect(() => {
if (!open) return;
const menu = menuRef.current;
if (!menu) return;
const focusableSelector =
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';
function handleTab(e: KeyboardEvent) {
if (e.key !== "Tab" || !menu) return;
const focusable = Array.from(
menu.querySelectorAll<HTMLElement>(focusableSelector),
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
menu.addEventListener("keydown", handleTab);
return () => menu.removeEventListener("keydown", handleTab);
}, [open]);
return (
<div ref={detailsRef} className="group relative md:hidden">
<button
ref={toggleRef}
type="button"
onClick={() => setOpen((o) => !o)}
className="list-none cursor-pointer flex items-center justify-center w-11 h-11 rounded-xl shrink-0
transition-colors duration-150
hover:bg-[color-mix(in_srgb,var(--color-primary)_12%,transparent)]
active:scale-[0.96]
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-primary-readable,var(--color-primary))] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-navbar)]"
aria-label={open ? closeLabel : menuLabel}
aria-expanded={open}
aria-haspopup="dialog"
aria-controls={menuId}
>
<svg
className="w-6 h-6 transition-transform duration-200"
style={{ transform: open ? "rotate(90deg)" : undefined }}
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="4" y1="7" x2="20" y2="7" />
<line x1="4" y1="12" x2="20" y2="12" />
<line x1="4" y1="17" x2="20" y2="17" />
</>
)}
</svg>
</button>
<MobileNavPanel
open={open}
menuId={menuId}
menuRef={menuRef}
brandLabel={brandLabel}
closeLabel={closeLabel}
>
{children}
</MobileNavPanel>
</div>
);
}