fix(auth,settings): clear offline caches on login and tighten settings a11y

Login only wiped the in-memory query cache; the IndexedDB query snapshot
and the service-worker data/page caches from the previous account stayed
on disk. Logout and the 401/expiry redirect already drop all three. Run
the same offline-cache wipe on a successful login too, before the new
user's dashboard prefetch warms, so a fresh sign-in on a shared browser
inherits nothing from the prior session. The per-account restore guard
still stands; this removes the stale data at the source.

Settings shell: move focus to the section heading when the active section
changes within the mounted shell, so keyboard and screen-reader users are
placed in the new content instead of being left on the nav link they
activated. The first mount is skipped to avoid an unexpected focus move on
a deep link. Raise the inactive sub-tab text from muted-foreground to
foreground/70 so it clears the WCAG AA contrast floor over the tinted
sub-tab strip.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 23:42:17 +02:00
1 parent 84543ad540
commit 7214b95982
2 files changed
+59 -2

No files matched your search

+11
View File
@@ -22,6 +22,7 @@ import {
import { MfaLoginStep, type MfaMethod } from "@/components/auth/mfa-login-step";
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
export default function LoginPage() {
const router = useRouter();
@@ -92,6 +93,16 @@ export default function LoginPage() {
// stale `["auth","me"]`, so the shell refetches it fresh on mount and the
// former explicit `invalidateQueries({ queryKey: auth() })` is redundant.
queryClient.clear();
// Belt-and-suspenders to match the session-END wipe (logout / 401-expiry):
// also drop the OFFLINE layers — the IndexedDB query snapshot and the SW
// data/page caches — so a previous account's persisted dashboard never
// lingers on disk for the new session. The restore is already per-account
// bound, but a fresh login on a shared browser should leave nothing of the
// prior account behind. Order is clear → prefetch → navigate: the wipe is
// started here (best-effort, like logout) before the prefetch warms THIS
// user's data, and the persister's 1 s debounce gives the single IDB delete
// ample headroom to land before the new snapshot is written.
void clearOfflineCachesForSessionEnd();
const target = getRedirectTarget();
if (target === "/" && isDashboardSnapshotEnabled()) {
prefetchDashboardSnapshot(queryClient);
+48 -2
View File
@@ -585,6 +585,39 @@ export function SettingsShell({
}
: null);
// v1.25.1 (A11Y-L6) — move focus to the section heading when the active
// section changes WITHIN the mounted shell (a sub-tab tap or a rail click;
// the shell instance is reused across `/settings/[section]` param changes,
// which is also why the mobile-strip effect above keys on `activeSlug`).
// Without this, keyboard / screen-reader users stay focused on the nav link
// they activated and the new content is never announced. The very first
// mount (a deep link or a fresh entry into Settings) is skipped on purpose:
// auto-focusing on initial page load is itself a jarring, unexpected focus
// move. The heading id is shared by two instances (mobile above the strip,
// desktop in the grid) — only one is visible per breakpoint — so we focus
// the visible one. `preventScroll` leaves the global scroll-reset in charge.
const headingId = resolvedHeading?.headingId;
const firstHeadingFocusRef = React.useRef(true);
React.useEffect(() => {
if (firstHeadingFocusRef.current) {
firstHeadingFocusRef.current = false;
return;
}
if (!headingId || typeof document === "undefined") return;
const candidates = document.querySelectorAll<HTMLElement>(
`[data-settings-heading="${headingId}"]`,
);
for (const el of candidates) {
// `offsetParent === null` ⇒ the element (or an ancestor) is
// `display:none` — i.e. the hidden-for-this-breakpoint instance. Skip it
// and focus the visible heading.
if (el.offsetParent !== null) {
el.focus({ preventScroll: true });
break;
}
}
}, [activeSlug, headingId]);
const headingBlock = resolvedHeading ? (
<div className="space-y-6">
{resolvedHeading.topSlot ? <div>{resolvedHeading.topSlot}</div> : null}
@@ -592,7 +625,14 @@ export function SettingsShell({
<div className="min-w-0">
<h1
id={resolvedHeading.headingId}
className="text-2xl font-bold tracking-tight"
data-settings-heading={resolvedHeading.headingId}
// v1.25.1 (A11Y-L6) — programmatically focusable so an in-shell
// section switch can move focus here (see the `activeSlug` effect).
// `tabIndex={-1}` keeps it out of the sequential tab order but lets
// `.focus()` land, so keyboard / screen-reader users are placed in
// the new section's content instead of being left on the nav link.
tabIndex={-1}
className="text-2xl font-bold tracking-tight outline-none"
>
{resolvedHeading.title}
</h1>
@@ -773,7 +813,13 @@ export function SettingsShell({
"flex min-h-9 items-center rounded-md px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
isActive
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
: // v1.25.1 (A11Y-L3) — inactive tab text over the
// `bg-muted/50` strip. `text-muted-foreground` is tuned
// for ~AA on the page background; over the tinted strip
// the effective ratio drops below 4.5:1 in light mode.
// `text-foreground/70` keeps the de-emphasised look but
// clears the WCAG AA floor on the strip.
"text-foreground/70 hover:text-foreground",
)}
>
{t(`settings.sections.${slug}.title`)}