Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
- Fixed JavaScript errors that could occur throughout the control panel. ([#19313](https://github.com/craftcms/cms/issues/19313))
- Fixed a bug where `forms.checkboxField()` and `CraftCms\Cms\Cp\FormFields::checkboxFieldHtml()` rendered an empty field. ([#19338](https://github.com/craftcms/cms/pull/19338))
- Fixed a bug where Utility pages weren’t rendering, and were logging `$ is not defined` and `window.Cp.config is not a function` errors to the console. ([#19340](https://github.com/craftcms/cms/pull/19340))
- Fixed a bug where `actionClient` requests for bare action paths could corrupt the `?site=` query string on multi-site installs. ([#19342](https://github.com/craftcms/cms/pull/19342))
- `Craft.cp.announce()` now accepts live regions that are plain elements as well as jQuery collections. ([#19340](https://github.com/craftcms/cms/pull/19340))

## 6.0.0-alpha.14 - 2026-07-22
Expand Down
80 changes: 80 additions & 0 deletions packages/craftcms-ui/src/utilities/api/actionClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import {AxiosHeaders, type InternalAxiosRequestConfig} from 'axios';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
import {ConfigService} from '../../services/Config';
import {actionClient} from './actionClient';

// The request interceptor references a bare `Cp` global and `window.Craft` when
// building action headers; provide inert stand-ins.
beforeEach(() => {
(globalThis as any).Cp = {registeredAssetBundles: [], registeredJsFiles: []};
(globalThis as any).Craft = {};
ConfigService.resetInstance();
});

afterEach(() => {
delete (globalThis as any).Cp;
delete (globalThis as any).Craft;
ConfigService.resetInstance();
});

function runRequestInterceptor(
url: string
): Promise<InternalAxiosRequestConfig> {
const handler = (actionClient.interceptors.request as any).handlers[0]
.fulfilled;

return handler({url, headers: new AxiosHeaders()});
}

describe('actionClient request URL resolution', () => {
it('preserves a query string on the action base URL for bare paths (multi-site)', async () => {
ConfigService.getInstance().initialize({
actionUrl: 'https://example.test/admin/actions?site=default',
});

const config = await runRequestInterceptor('users/confirm-password');

// The path must extend the pathname and keep the query intact — NOT
// `?site=default/users/confirm-password`, which is what naive baseURL
// string concatenation produced.
expect(config.url).toBe(
'https://example.test/admin/actions/users/confirm-password?site=default'
);
});

it('expands bare paths against a clean action base URL (single-site)', async () => {
ConfigService.getInstance().initialize({
actionUrl: 'https://example.test/admin/actions',
});

const config = await runRequestInterceptor('auth/verify-totp');

expect(config.url).toBe(
'https://example.test/admin/actions/auth/verify-totp'
);
});

it('resolves /-prefixed Wayfinder route paths against the origin only', async () => {
ConfigService.getInstance().initialize({
actionUrl: 'https://example.test/admin/actions?site=default',
});

const config = await runRequestInterceptor(
'/admin/actions/fields/render-settings'
);

expect(config.baseURL).toBe('https://example.test');
expect(config.url).toBe('/admin/actions/fields/render-settings');
});

it('leaves absolute URLs untouched', async () => {
ConfigService.getInstance().initialize({
actionUrl: 'https://example.test/admin/actions',
});

const config = await runRequestInterceptor('https://other.test/thing');

expect(config.url).toBe('https://other.test/thing');
expect(config.baseURL).toBeUndefined();
});
});
39 changes: 22 additions & 17 deletions packages/craftcms-ui/src/utilities/api/actionClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,26 +44,31 @@ 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; the
// config isn't guaranteed to be initialized when this module is first
// imported. Request URLs come in two shapes, told apart by their leading
// character:
// Resolve the URL lazily so it reflects the runtime CP trigger; the config
// isn't guaranteed to be initialized when this module is first imported.
// Request URLs come in three shapes:
//
// - A bare action path (e.g. `users/confirm-password`) is expanded to the
// full action URL via `ConfigService.getActionUrl()`, which inserts the
// path into the *pathname*. This preserves any query string on the action
// base URL — notably `?site=` on multi-site installs — which naive
// `baseURL` + path string concatenation would corrupt by appending the
// path after the query (`?site=default/users/confirm-password`).
// - A route path starting with `/` (e.g. a Wayfinder-generated
// `/admin/actions/fields/render-settings`) already carries the CP/action
// triggers, so it resolves against the origin only. `URL.origin`
// supplies scheme + host (+ port) without the `protocol` trailing-colon
// / port-doubling pitfalls.
// - A bare action path (e.g. `users/confirm-password`) resolves against
// the full action base URL (`Url::actionUrl()`), which carries the
// triggers for it.
//
// Absolute URLs skip `baseURL` entirely, per axios semantics.
const actionUrl = getActionUrl();
config.baseURL =
config.url && !config.url.startsWith('/')
? actionUrl.replace(/\/+$/, '')
: new URL(actionUrl).origin;
// triggers, so it resolves against the origin only. `URL.origin` supplies
// scheme + host (+ port) without the `protocol` trailing-colon /
// port-doubling pitfalls.
// - An absolute URL is left untouched, per axios semantics.
if (
config.url &&
!config.url.startsWith('/') &&
!/^[a-z][a-z\d+.-]*:/i.test(config.url)
) {
config.url = getActionUrl(config.url);
} else if (config.url?.startsWith('/')) {
config.baseURL = new URL(getActionUrl()).origin;
}

// Set X-Requested-With header
config.headers.set('X-Requested-With', 'XMLHttpRequest');
Expand Down
231 changes: 231 additions & 0 deletions resources/js/pages/users/Passkeys.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
<script setup lang="ts">
import {h, onMounted, ref} from 'vue';
import {router, useHttp, usePage} from '@inertiajs/vue3';
import {t} from '@craftcms/ui';
import {
browserSupportsWebAuthn,
platformAuthenticatorIsAvailable,
startRegistration,
} from '@simplewebauthn/browser';
import {getCoreRowModel, useVueTable} from '@tanstack/vue-table';
import Pane from '@/common/components/Pane.vue';
import CraftDate from '@/common/components/Date.vue';
import AdminTable from '@/modules/admin-table/components/AdminTable.vue';
import {createCraftColumnHelper} from '@/modules/admin-table/helpers/createCraftColumnHelper';
import {elevatedSessionManager} from '@/modules/auth/elevated-session';
import {
creationOptions,
verifyCreation,
deleteMethod as deletePasskeyAction,
} from '@actions/Users/PasskeysController';

defineOptions({
inheritAttrs: false,
});

const page = usePage<CraftCms.Cms.Http.ViewModels.UserPasskeysViewModel>();

type Passkey = (typeof page.props.passkeys)[number];

// Re-request the full elevated-session window (capped at 5 minutes) so it
// survives the browser's WebAuthn prompt during registration.
const ELEVATED_SETUP_SECONDS = 300;

const craft = (window as any).Craft;

const supported = ref(true);
const adding = ref(false);
const processingUid = ref<string | null>(null);

const optionsRequest = useHttp<Record<string, never>, {options: string}>({});
const verifyRequest = useHttp<
{credentials: string; credentialName: string | null},
unknown
>({credentials: '', credentialName: null});
const deleteRequest = useHttp<{uid: string}, unknown>({uid: ''});

onMounted(async () => {
supported.value =
browserSupportsWebAuthn() && (await platformAuthenticatorIsAvailable());
});

function refresh() {
router.reload({only: ['passkeys']});
}

// Suggest a sensible default passkey name, e.g. "Chrome on Mac".
function browserName(): string {
const ua = navigator.userAgent;
if (/edg/i.test(ua)) return 'Edge';
if (/opr\//i.test(ua)) return 'Opera';
if (/chrome|chromium|crios/i.test(ua)) return 'Chrome';
if (/firefox|fxios/i.test(ua)) return 'Firefox';
if (/safari/i.test(ua)) return 'Safari';
return 'Browser';
}

function platformName(): string {
const platform = navigator.platform;
const known = ['Mac', 'iPhone', 'iPad', 'iPod', 'Linux', 'Win'];
const match = known.find((name) => platform.includes(name));
if (match === 'Win') return 'Windows';
return match ?? platform;
}

async function addPasskey() {
if (adding.value) {
return;
}

adding.value = true;

try {
const confirmed = await elevatedSessionManager.require({
minimumRemainingSeconds: ELEVATED_SETUP_SECONDS,
});

if (!confirmed) {
return;
}

const optionsResponse = await optionsRequest.post(creationOptions().url);

if (!optionsResponse) {
return;
}

const credentialName = window.prompt(
t('Enter a name for the passkey.'),
`${browserName()} on ${platformName()}`
);

if (credentialName === null) {
return;
}

let registration;

try {
registration = await startRegistration({
optionsJSON: JSON.parse(optionsResponse.options),
});
} catch (e: any) {
craft?.cp?.displayError?.(e?.message);
return;
}

verifyRequest.credentials = JSON.stringify(registration);
verifyRequest.credentialName = credentialName;

const verified = await verifyRequest.post(verifyCreation().url);

if (verified) {
refresh();
}
} finally {
adding.value = false;
}
}

async function removePasskey(passkey: Passkey) {
if (
!confirm(
t('Are you sure you want to delete the “{name}” passkey?', {
name: passkey.name,
})
)
) {
return;
}

processingUid.value = passkey.uid;

try {
deleteRequest.uid = passkey.uid;

const response = await deleteRequest.post(deletePasskeyAction().url);

if (response) {
refresh();
}
} finally {
processingUid.value = null;
}
}

const columnHelper = createCraftColumnHelper<Passkey>();
const table = useVueTable<Passkey>({
get data() {
return page.props.passkeys;
},
get columns() {
return [
columnHelper.display({
id: 'name',
header: t('Name'),
cell: ({row}) => h('span', {class: 'font-bold'}, row.original.name),
}),
columnHelper.display({
id: 'dateLastUsed',
header: t('Last Used'),
cell: ({row}) =>
row.original.dateLastUsed
? h(CraftDate, {value: row.original.dateLastUsed})
: t('Never'),
}),
columnHelper.actions(({row}) => [
h(
'craft-button',
{
type: 'button',
size: 'small',
icon: 'trash',
'aria-label': t('Delete {name}', {name: row.original.name}),
loading: processingUid.value === row.original.uid,
onclick: () => removePasskey(row.original),
},
t('Delete')
),
]),
];
},
getCoreRowModel: getCoreRowModel<Passkey>(),
enableSorting: false,
});
</script>

<template>
<craft-pane>
<div class="grid gap-4">
<div>
<h2>{{ t('Passkeys') }}</h2>
<p>
{{
t(
'Passkeys are an easy and secure way to identify yourself, using your fingerprint or facial recognition.'
)
}}
</p>
</div>

<craft-callout v-if="!supported" variant="warning">
{{ t('This browser doesn’t support passkeys.') }}
</craft-callout>

<Pane :padding="0" appearance="raised">
<AdminTable :table="table" />
</Pane>

<div v-if="supported">
<craft-button
type="button"
icon="plus"
:loading="adding"
@click="addPasskey"
>
{{ t('Add a passkey') }}
</craft-button>
</div>
</div>
</craft-pane>
</template>
Loading
Loading