Files
projectsend/resources/js/app.tsx
T
ignacionelson a45eae315c Ask for the password over the page instead of throwing the form away
password.confirm redirected every write to the confirm-password screen.
A redirect cannot carry a POST body, and Redirector::guest() only
remembers the exact URL of a GET, so after confirming, the user landed
back on an empty form and the action never ran. On the API token forms
that meant typing the name, the scopes and the expiry again.

An Inertia request now gets a 423 marked X-Password-Confirmation. A
dialog mounted around every page catches it, asks for the password over
the current page, and sends the refused request again with the same data
and callbacks, so the form finishes as if nothing happened. The check
itself is still the framework's. Plain form posts and JSON clients are
answered as before, and accounts with no local password are offered a
way to set one, as the confirm screen does.
2026-09-21 18:05:54 -03:00

110 lines
4.5 KiB
TypeScript

import '../css/app.css';
import { createInertiaApp, router } from '@inertiajs/react';
import axios from 'axios';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import { createElement } from 'react';
import { createRoot } from 'react-dom/client';
import { route as routeFn } from 'ziggy-js';
import { PasswordConfirmationDialog } from './components/password-confirmation-dialog';
import { initializeTheme } from './hooks/use-appearance';
import { xsrfCookieName } from './lib/xsrf';
declare global {
const route: typeof routeFn;
}
// Inertia sends every write through axios, which reads the CSRF token
// from a cookie it expects to be called `XSRF-TOKEN`. This installation
// names that cookie after itself so a neighbouring Laravel app on the same
// hostname cannot overwrite it — so axios has to be told. Without this,
// every write 419s the moment a neighbour answers a request.
//
// Set on every request rather than once here: an SPA-style Inertia visit
// (a redirect after a POST, for instance) never re-runs this module, so a
// value captured once at load can go stale the moment the server rotates
// the cookie mid-session — the exact failure xsrf.ts's own docblock warns
// about, and the reason it says to read the name fresh on every call.
axios.interceptors.request.use((config) => {
config.xsrfCookieName = xsrfCookieName();
return config;
});
/**
* The suffix on every browser tab title.
*
* Read from the shared props — the site name an administrator set — and
* never from `import.meta.env`. Vite resolves those at build time, and the
* published release ships `public/build/` already compiled, so whatever a
* build machine happened to have is frozen for every install downstream and
* no setting can move it afterwards. That is how 2.0.0 came to tell every
* visitor its tabs were "Laravel".
*/
let appName = 'ProjectSend';
const siteName = (page: { props: Record<string, unknown> }): string | null => {
const name = page.props.name;
return typeof name === 'string' && name !== '' ? name : null;
};
// Pages shipped by packages, re-keyed to look exactly like a host page
// (`./pages/<name>.tsx`) so resolvePageComponent finds them the same
// way — a one-time, generic extension point so a new package's pages
// just work without touching this file again.
//
// vendor/ is the only location globbed, and it covers both ways a
// package arrives: `composer require` puts a published package there,
// and a path repository (how a dev checkout consumes a local clone)
// symlinks the clone
// there too. Globbing packages/* as well, which this did at first,
// matched the dev checkouts' pages through both paths and emitted every
// package page into the bundle twice.
const packagePages: Record<string, () => Promise<unknown>> = {};
for (const [path, loader] of Object.entries(import.meta.glob('../../vendor/*/*/resources/js/pages/**/*.tsx'))) {
const match = path.match(/resources\/js\/pages\/(.+)$/);
if (match) packagePages[`./pages/${match[1]}`] = loader;
}
createInertiaApp({
title: (title) => `${title} - ${appName}`,
resolve: (name) => resolvePageComponent(`./pages/${name}.tsx`, { ...packagePages, ...import.meta.glob('./pages/**/*.tsx') }),
setup({ el, App, props }) {
// Before the first render, so the title callback below already has
// it when the initial page produces its <Head>.
appName = siteName(props.initialPage) ?? appName;
const root = createRoot(el);
// The password dialog sits beside every page rather than in any
// one layout: the writes it answers for are spread across the
// staff shell, the settings screens and every portal theme, and
// it needs the page context (translations) the children of <App>
// are given. The page itself renders as Inertia would on its own.
root.render(
<App {...props}>
{({ Component, props: pageProps, key }) => (
<>
{createElement(Component, { key, ...pageProps })}
<PasswordConfirmationDialog />
</>
)}
</App>,
);
},
progress: {
color: '#4B5563',
},
});
// Renaming the site is a settings save like any other, so the new name
// arrives on the very next visit — without this the tabs would keep the
// old one until someone reloaded the page.
router.on('navigate', (event) => {
appName = siteName(event.detail.page) ?? appName;
});
// This will set light / dark mode on load...
initializeTheme();