Files
pl_mail/templates/_partials/_session_expired.html.twig
Paul LütznerandClaude Opus 5 0f80d2c43b fix(auth): a subresource is not a destination, and logging out is a full load
Three reports, one story: the application kept treating a dead session as an
ordinary navigation.

**Landing on your own profile picture after signing in.** Symfony saves the
login target in ExceptionListener::setTargetPath(), whose test is
`isMethodSafe() && !isXmlHttpRequest()` — and an `<img src="/…/avatar">` is
both. So the last unauthenticated thing the browser happened to fetch became
the place the next login went. The session ends, the page is still on screen,
the browser re-requests the avatar, that 302 saves an image URL, and login
redirects to it. Whichever subresource lost the race decided the destination,
which is why it looked intermittent rather than broken.

The entry point is where to undo it, because it runs after the save. Anything
that is not a document navigation has the saved path removed again. The test is
two-sided rather than a list of extensions: `Sec-Fetch-Dest: document` is what a
browser says about a real navigation, and an Accept mentioning text/html covers
a Turbo visit — which is a fetch, and reports `Sec-Fetch-Dest: empty`. Both
halves are pinned, so the fix cannot degrade into "never remember anything":
asking for /settings while signed out still returns you to /settings.

**Logout went through Turbo.** The shell survived it — controllers still
mounted and polling with a cookie that no longer existed, the Mercure
subscription still open, and Turbo's page cache able to put a signed-in
snapshot back with the back button. data-turbo="false".

**A session ending underneath an open page** made Turbo swap the login form
into the application shell: a password box wearing the sidebar, with every
controller on the page still running and failing. There is a bar now.

The interesting decision is in that bar. "Reload the page" is the obvious
response and it is wrong exactly when it matters: a half-written mail lives in a
contenteditable, and the autosave that would preserve it is a POST — the thing
that has just stopped working. So with nothing being written it offers a
straightforward sign-in in this tab, and with something being written it offers
a new tab instead and leaves this one untouched, text and all. The cookie is
shared across tabs, so coming back here just works and the next autosave lands.
Being wrong in that direction costs a tab; being wrong the other way costs what
somebody wrote.

Also: the compose window's close button gets an aria-label (CODE-REVIEW U-01).
Its only content is an icon, so with `title` alone the accessible name depended
on a screen reader choosing to fall back to it, and on touch it was unreachable
— while above md it is the only way out of the window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 12:33:45 +02:00

47 lines
2.1 KiB
Twig

{#
The bar that appears when the session ends underneath a page that is still open.
Rendered on every page and hidden, rather than built in JavaScript, because
every string in it needs translating and translation is a server-side concern
here. The controller only unhides it and chooses which of the two sentences to
show.
Two sentences, and the difference is the whole point of the feature. With
nothing being written, ending up on the login page is what the user wants and
the bar just says so. With a half-written mail on screen, a redirect would throw
away everything since the last autosave — so the offer is to sign in in a new
tab and come back to a page that still has the text in it.
#}
<div id="session-expired"
data-controller="core--session-guard"
data-core--session-guard-login-url-value="{{ path('app_login') }}"
class="hidden fixed inset-x-0 top-0 z-[300] px-4 py-3
bg-surface border-b-2 border-danger shadow-float"
role="alert"
aria-live="assertive">
<div class="mx-auto flex max-w-3xl items-center gap-3 text-sm">
<i class="fa-solid fa-circle-exclamation shrink-0 text-danger" aria-hidden="true"></i>
<div class="min-w-0 flex-1">
<p class="font-medium text-ink">{{ 'session.expired.heading'|trans }}</p>
<p class="text-ink-soft"
data-core--session-guard-target="body"
data-body="{{ 'session.expired.body'|trans }}"
data-body-writing="{{ 'session.expired.body_writing'|trans }}"></p>
</div>
<a href="{{ path('app_login') }}"
data-turbo="false"
data-core--session-guard-target="signIn"
data-label="{{ 'session.expired.sign_in'|trans }}"
data-label-new-tab="{{ 'session.expired.sign_in_new_tab'|trans }}"
class="shrink-0 rounded-full bg-accent px-4 py-2 font-medium text-on-accent"></a>
<button type="button"
data-action="click->core--session-guard#reload"
class="shrink-0 rounded-full border border-line px-4 py-2 font-medium text-ink-soft hover:bg-hover">
{{ 'session.expired.reload'|trans }}
</button>
</div>
</div>