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
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.
173 lines
4.5 KiB
TypeScript
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>
|
|
);
|
|
}
|