Files
mash2k3 856c13b09c Invite a client to register instead of handing them a password (#1780)
Staff can now invite a specific address to register instead of typing a
password for somebody and finding a way to get it to them. The invited
person sets their own, the link is locked to the address it was sent to,
and an invitation always activates the account regardless of the
auto-approve setting -- naming an address is already the decision the
approval queue exists to make for one nobody named.

Two fixes ride along: outgoing mail now reads the installation's own site
name in its title, header and signature rather than the one baked into
config('app.name') at install time, and the CSRF cookie name is read per
request rather than captured once at load.

Follow-up work, tracked separately: an invitation cannot be cancelled --
there is no pending-invitations screen and no revoke, so letting one expire
is the only way to take it back, which the self-service resend button then
undoes. Redemption also needs the address-availability check every other
non-form caller of ClientProvisioning makes.

Thanks @mash2k3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe
2026-09-12 14:28:47 -03:00

94 lines
3.8 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 { createRoot } from 'react-dom/client';
import { route as routeFn } from 'ziggy-js';
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);
root.render(<App {...props} />);
},
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();