diff --git a/packages/craftcms-cp/src/utilities/api/actionClient.ts b/packages/craftcms-cp/src/utilities/api/actionClient.ts index 4969ff9b1a4..6cac50742ce 100644 --- a/packages/craftcms-cp/src/utilities/api/actionClient.ts +++ b/packages/craftcms-cp/src/utilities/api/actionClient.ts @@ -1,11 +1,13 @@ import axios, {type RawAxiosRequestHeaders} from 'axios'; import {Csrf} from '@src/services/Csrf'; +import {ConfigService} from '@src/services/Config'; /** - * @TODO + * Builds an action URL using the runtime-configured action base + * (`Url::actionUrl()`), so the CP trigger isn't hard-coded to `/admin`. */ export function getActionUrl(action: string = '') { - return `/admin/actions/${action}`; + return ConfigService.getInstance().getActionUrl(action); } /** @@ -27,13 +29,15 @@ export function actionHeaders(): RawAxiosRequestHeaders { return headers; } -export const actionClient = axios.create({ - baseURL: getActionUrl(), -}); +export const actionClient = axios.create(); const csrf = new Csrf(); actionClient.interceptors.request.use(async (config) => { + // Resolve the base URL lazily so it reflects the runtime CP trigger. Config + // isn't guaranteed to be initialized when this module is first imported. + config.baseURL = getActionUrl(); + // Set X-Requested-With header config.headers.set('X-Requested-With', 'XMLHttpRequest'); diff --git a/resources/js/bootstrap/cp.ts b/resources/js/bootstrap/cp.ts index 60275f799cb..5fb8c5e0188 100644 --- a/resources/js/bootstrap/cp.ts +++ b/resources/js/bootstrap/cp.ts @@ -15,6 +15,7 @@ import AssetIndexes from '@/modules/utilities/components/asset-indexes/AssetInde import SystemMessages from '@/modules/utilities/components/system-messages/SystemMessages.vue'; import DeprecationErrorsToolbar from '@/modules/utilities/components/deprecation-errors/DeprecationErrorsToolbar.vue'; import {setTranslations} from '@craftcms/cp/utilities/translate.ts.mjs'; +import {setCpTrigger} from '@/wayfinder/cp-trigger'; let bootedCallbacks: Array<(instance: any) => void> = []; let bootingCallbacks: Array<(instance: any) => void> = []; @@ -53,6 +54,9 @@ const Cp = { init() { config.initialize(this.initialConfig); + + // Make Wayfinder-generated route URLs use the runtime-configured CP trigger. + setCpTrigger(config.get('cpTrigger')); queue.initialize({ runAutomatically: config.get('runQueueAutomatically', true), enabled: true, diff --git a/resources/js/modules/install/components/InstallingScreen.vue b/resources/js/modules/install/components/InstallingScreen.vue index 694a04bf818..39e2968e6eb 100644 --- a/resources/js/modules/install/components/InstallingScreen.vue +++ b/resources/js/modules/install/components/InstallingScreen.vue @@ -4,8 +4,10 @@ import {usePost} from '@/common/composables/useFetch'; import {usePage} from '@inertiajs/vue3'; import Pane from '@/common/components/Pane.vue'; + import useCraftData from '@/common/composables/useCraftData'; const {props: pageProps} = usePage(); + const {general} = useCraftData(); const props = defineProps<{ data: any; @@ -17,7 +19,7 @@ isSuccess, isLoading, isError, - } = usePost('/admin/actions/install/install', { + } = usePost(`/${general.cpTrigger}/actions/install/install`, { onSuccess: () => { setTimeout(() => { window.location.href = pageProps.postCpLoginRedirect as string; diff --git a/resources/js/modules/updater/composables/useUpdater.ts b/resources/js/modules/updater/composables/useUpdater.ts index 9b4416d2ddd..1f96bef67d3 100644 --- a/resources/js/modules/updater/composables/useUpdater.ts +++ b/resources/js/modules/updater/composables/useUpdater.ts @@ -1,6 +1,7 @@ import {computed, type ComputedRef, type Ref, ref} from 'vue'; import {t} from '@craftcms/cp'; import axios from 'axios'; +import useCraftData from '@/common/composables/useCraftData'; /** * State returned from updater API endpoints @@ -47,6 +48,7 @@ export function useUpdater( actionPrefix: string, initialState: UpdaterState ): UseUpdaterReturn { + const {general} = useCraftData(); const state = ref({...initialState}); const isLoading = ref(false); @@ -62,7 +64,7 @@ export function useUpdater( try { response = await axios.post( - `/admin/actions/${actionPrefix}/${action}`, + `/${general.cpTrigger}/actions/${actionPrefix}/${action}`, {data: state.value.data}, { headers: { @@ -157,7 +159,7 @@ export function useUpdater( // Try to disable maintenance mode axios .post( - `/admin/actions/${actionPrefix}/finish`, + `/${general.cpTrigger}/actions/${actionPrefix}/finish`, {data: state.value.data}, { headers: { diff --git a/resources/js/pages/updater/Index.vue b/resources/js/pages/updater/Index.vue index dd7af27374f..b686749f448 100644 --- a/resources/js/pages/updater/Index.vue +++ b/resources/js/pages/updater/Index.vue @@ -7,6 +7,9 @@ type UpdaterState, useUpdater, } from '@/modules/updater/composables/useUpdater'; + import useCraftData from '@/common/composables/useCraftData'; + + const {general} = useCraftData(); const props = defineProps<{ title: string; @@ -41,7 +44,9 @@ function handleFinish(): void { setTimeout(() => { window.location.href = - state.value.returnUrl || props.returnUrl || '/admin/dashboard'; + state.value.returnUrl || + props.returnUrl || + `/${general.cpTrigger}/dashboard`; }, 750); } diff --git a/scripts/wayfinder-cp-trigger.mjs b/scripts/wayfinder-cp-trigger.mjs new file mode 100644 index 00000000000..070a2bad43f --- /dev/null +++ b/scripts/wayfinder-cp-trigger.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +/** + * Rewrites Wayfinder-generated route modules so their URLs respect the + * runtime-configured CP trigger (`Cms::config()->cpTrigger`) instead of the + * `/admin` prefix baked in at generation time. + * + * Every generated `*.url()` body ends in `+ queryParams(options)` (and `return` + * appears nowhere else in generated files), so the base path can be reliably + * wrapped in `cpUrl(...)` from `resources/js/wayfinder/cp-trigger.ts`. + * + * This runs as a Vite `transform` hook (see `vite.config.js`) rather than a + * post-generation file rewrite: the `@laravel/vite-plugin-wayfinder` plugin + * regenerates these files on the fly, which would clobber any on-disk edits. + * Transforming at import time keeps the generated files pristine while still + * applying to both dev and production builds. + */ + +/** + * Wraps a generated route module's URL bases in `cpUrl(...)` and injects the + * import. Returns the transformed source, or `null` when nothing applies (no + * URL builders, or already transformed). + * + * @param {string} code + * @returns {string | null} + */ +export function transformWayfinderSource(code) { + // Nothing to wrap, or already processed. + if (!code.includes(' + queryParams(options)') || code.includes('cpUrl(')) { + return null; + } + + // Wrap each url() base path (everything between `return ` and the trailing + // `+ queryParams(options)`) in cpUrl(). + const wrapped = code.replace( + /return ([\s\S]*?) \+ queryParams\(options\)/g, + 'return cpUrl($1) + queryParams(options)' + ); + + if (wrapped === code) { + return null; + } + + // Add the cpUrl import as a sibling of the existing wayfinder import, reusing + // its (depth-specific) relative module specifier. + return wrapped.replace( + /^(import .*from '(.*\/wayfinder)')$/m, + "$1\nimport { cpUrl } from '$2/cp-trigger'" + ); +} + +/** True for Wayfinder-generated route module ids that should be transformed. */ +export function isWayfinderRouteModule(id) { + const normalized = id.split('?')[0].replace(/\\/g, '/'); + return ( + /\/resources\/js\/(actions|routes)\//.test(normalized) && + normalized.endsWith('.ts') + ); +} diff --git a/vite.config.js b/vite.config.js index 81e593ac318..142deaeefd9 100644 --- a/vite.config.js +++ b/vite.config.js @@ -8,9 +8,34 @@ import {promisify} from 'util'; import vue from '@vitejs/plugin-vue'; import tailwindcss from '@tailwindcss/vite'; import {wayfinder} from '@laravel/vite-plugin-wayfinder'; +import { + transformWayfinderSource, + isWayfinderRouteModule, +} from './scripts/wayfinder-cp-trigger.mjs'; const execAsync = promisify(exec); +/** + * Rewrites Wayfinder-generated route URLs to use the runtime CP trigger instead + * of the `/admin` prefix baked in at generation time. Runs at import time so it + * survives the wayfinder() plugin regenerating the files on disk. + */ +function wayfinderCpTrigger() { + return { + name: 'wayfinder-cp-trigger', + enforce: 'pre', + transform(code, id) { + if (!isWayfinderRouteModule(id)) { + return null; + } + + const transformed = transformWayfinderSource(code); + + return transformed ? {code: transformed, map: null} : null; + }, + }; +} + const MIME_TYPES = { '.js': 'application/javascript', '.mjs': 'application/javascript', @@ -151,6 +176,7 @@ export default defineConfig(({mode}) => { path: 'resources/js', command: './vendor/bin/testbench wayfinder:generate', }), + wayfinderCpTrigger(), vue({ template: { compilerOptions: {