From 9a09275eee6ccc6ac84101e2b9af94e16bb59424 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Wed, 6 May 2026 09:55:59 -0500 Subject: [PATCH 1/3] Initial login flow --- package-lock.json | 51 ++++++ packages/craftcms-cp/package.json | 3 + packages/craftcms-cp/src/utilities/format.ts | 16 +- resources/js/components/Auth/LoginForm.vue | 146 ++++++++++++++++++ resources/js/composables/useCraftData.ts | 24 ++- resources/js/pages/Login.vue | 41 +++++ routes/actions.php | 1 - routes/cp.php | 4 +- src/Cp/Cp.php | 8 +- src/Http/Controllers/Auth/LoginController.php | 19 ++- src/Http/Middleware/HandleInertiaRequests.php | 17 +- 11 files changed, 305 insertions(+), 25 deletions(-) create mode 100644 resources/js/components/Auth/LoginForm.vue create mode 100644 resources/js/pages/Login.vue diff --git a/package-lock.json b/package-lock.json index a2210041c53..c437d735b62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2733,6 +2733,37 @@ "version": "0.2.11", "license": "MIT" }, + "node_modules/@formatjs/bigdecimal": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@formatjs/bigdecimal/-/bigdecimal-0.2.3.tgz", + "integrity": "sha512-d7LpumdsbHueHdlVMos2yROIumyisUJNlFAk3PpN/5YcnXhWnkYmeiaQnOjqmmGx8BbaphoIyaF2CPus3cownw==", + "license": "MIT" + }, + "node_modules/@formatjs/fast-memoize": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.4.tgz", + "integrity": "sha512-Lbke1aOrsygKKR09Ux0NrZgbTqpDmiwXOgzyDOJ8Owr1zd5qOKTauf62hH+Seeku3ju77rHWH9I5SfX2CN0vuA==", + "license": "MIT" + }, + "node_modules/@formatjs/intl-durationformat": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/@formatjs/intl-durationformat/-/intl-durationformat-0.10.8.tgz", + "integrity": "sha512-1Crir41n1kMTVejBg7tkLfBRSWm7p1y+Bt4Nyny1DtxH41/+q2qZ2vSyTiQEdKbvSvmt0WG/gInFrMsK8lx1/A==", + "license": "MIT", + "dependencies": { + "@formatjs/bigdecimal": "0.2.3", + "@formatjs/intl-localematcher": "0.8.6" + } + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.6.tgz", + "integrity": "sha512-AZRgUxj0q93lyF7Z5lFS85bLINXuBLX4R3tCKicO6fSWo6cvh9GQfoR3B1WlsqQwefZ1QORTivhInx7gM6HUzQ==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "3.1.4" + } + }, "node_modules/@fortawesome/fontawesome-common-types": { "version": "6.7.2", "license": "MIT", @@ -5738,6 +5769,13 @@ "@types/node": "*" } }, + "node_modules/@types/humanize-duration": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@types/humanize-duration/-/humanize-duration-3.27.4.tgz", + "integrity": "sha512-yaf7kan2Sq0goxpbcwTQ+8E9RP6HutFBPv74T/IA/ojcHKhuKVlk2YFYyHhWZeLvZPzzLE3aatuQB4h0iqyyUA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/inquirer": { "version": "9.0.9", "dev": true, @@ -12260,6 +12298,16 @@ "node": ">=10.17.0" } }, + "node_modules/humanize-duration": { + "version": "3.33.2", + "resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.33.2.tgz", + "integrity": "sha512-K7Ny/ULO1hDm2nnhvAY+SJV1skxFb61fd073SG1IWJl+D44ULrruCuTyjHKjBVVcSuTlnY99DKtgEG39CM5QOQ==", + "dev": true, + "license": "Unlicense", + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, "node_modules/humanize-ms": { "version": "1.2.1", "license": "MIT", @@ -22358,6 +22406,7 @@ "license": "MIT", "dependencies": { "@chromatic-com/storybook": "^5.1.2", + "@formatjs/intl-durationformat": "^0.10.8", "@github/relative-time-element": "^5.0.0", "axios": "^1.15.2" }, @@ -22374,6 +22423,7 @@ "@storybook/addon-vitest": "^10.3.6", "@storybook/web-components-vite": "^10.3.6", "@total-typescript/tsconfig": "^1.0.4", + "@types/humanize-duration": "^3.27.4", "@types/jquery": "^4.0.0", "@types/node": "^25.6.0", "@vitest/browser": "^4.1.5", @@ -22384,6 +22434,7 @@ "esbuild": "^0.28.0", "globby": "^16.2.0", "happy-dom": "^20.9.0", + "humanize-duration": "^3.33.2", "lit": "^3.3.2", "ora": "^9.4.0", "playwright": "^1.59.1", diff --git a/packages/craftcms-cp/package.json b/packages/craftcms-cp/package.json index 0629e0a6600..0d7dc6f19ea 100644 --- a/packages/craftcms-cp/package.json +++ b/packages/craftcms-cp/package.json @@ -73,6 +73,7 @@ "@storybook/addon-vitest": "^10.3.6", "@storybook/web-components-vite": "^10.3.6", "@total-typescript/tsconfig": "^1.0.4", + "@types/humanize-duration": "^3.27.4", "@types/jquery": "^4.0.0", "@types/node": "^25.6.0", "@vitest/browser": "^4.1.5", @@ -83,6 +84,7 @@ "esbuild": "^0.28.0", "globby": "^16.2.0", "happy-dom": "^20.9.0", + "humanize-duration": "^3.33.2", "lit": "^3.3.2", "ora": "^9.4.0", "playwright": "^1.59.1", @@ -99,6 +101,7 @@ }, "dependencies": { "@chromatic-com/storybook": "^5.1.2", + "@formatjs/intl-durationformat": "^0.10.8", "@github/relative-time-element": "^5.0.0", "axios": "^1.15.2" }, diff --git a/packages/craftcms-cp/src/utilities/format.ts b/packages/craftcms-cp/src/utilities/format.ts index fdeae1df503..db6a02bb3ac 100644 --- a/packages/craftcms-cp/src/utilities/format.ts +++ b/packages/craftcms-cp/src/utilities/format.ts @@ -1,3 +1,8 @@ +import humanizeDuration from 'humanize-duration'; + +// @TODO grab this from the config +const defaultCpLocale = 'en'; + export function formatNumber(number: number | string, format?: string) { // If d3 is available, use it for compatibility if ( @@ -27,7 +32,7 @@ export function formatNumber(number: number | string, format?: string) { ? parseInt(decimalMatch[1]!, 10) : 0; - return new Intl.NumberFormat('en-US', { + return new Intl.NumberFormat(defaultCpLocale, { useGrouping: hasThousandSeparator, minimumFractionDigits: maximumFractionDigits, maximumFractionDigits: maximumFractionDigits, @@ -35,9 +40,16 @@ export function formatNumber(number: number | string, format?: string) { } // Default: format with thousand separators, no decimal places - return new Intl.NumberFormat('en-US', { + return new Intl.NumberFormat(defaultCpLocale, { useGrouping: true, minimumFractionDigits: 0, maximumFractionDigits: 0, }).format(num); } + +export const cpHumanizer = humanizeDuration.humanizer({ + language: defaultCpLocale, + fallbacks: ['en'], + largest: 2, + round: true, +}); diff --git a/resources/js/components/Auth/LoginForm.vue b/resources/js/components/Auth/LoginForm.vue new file mode 100644 index 00000000000..f4c79acf077 --- /dev/null +++ b/resources/js/components/Auth/LoginForm.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/resources/js/composables/useCraftData.ts b/resources/js/composables/useCraftData.ts index 27bf8f8a3af..2ee4b52d4d3 100644 --- a/resources/js/composables/useCraftData.ts +++ b/resources/js/composables/useCraftData.ts @@ -1,5 +1,13 @@ import {usePage} from '@inertiajs/vue3'; +export interface CpUser { + username: string | null; + email: string | null; + id: number | null; + thumbHtml: string | null; + name: string | null; +} + export interface CraftData { system: { name: string; @@ -18,15 +26,17 @@ export interface CraftData { }; readOnly: boolean; allowAdminChanges: boolean; - currentUser: { - username: string | null; - email: string | null; - id: number | null; - thumbHtml: string | null; - name: string | null; - }; + currentUser: CpUser | null; general: { + cpTrigger: string; + actionTrigger: string | null; + csrfTokenName: string | null; + cpLogoUrl: string | null; useEmailAsUsername: boolean; + rememberedUserSessionDuration: number; + defaultCpLocale: string; + readOnly: boolean; + allowAdminChanges: boolean; }; nav: any[]; [key: string]: any; diff --git a/resources/js/pages/Login.vue b/resources/js/pages/Login.vue new file mode 100644 index 00000000000..9ecda4459e9 --- /dev/null +++ b/resources/js/pages/Login.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/routes/actions.php b/routes/actions.php index 7c80b58fafd..a4231811a2a 100644 --- a/routes/actions.php +++ b/routes/actions.php @@ -127,7 +127,6 @@ Route::get('app/health-check', HealthCheckController::class); // Auth - Route::post('users/login', [LoginController::class, 'attemptLogin']); Route::post('auth/verify-totp', [TwoFactorAuthenticationController::class, 'verify']); Route::post('auth/verify-recovery-code', [TwoFactorAuthenticationController::class, 'verifyRecoveryCode']); Route::post('auth/passkey-request-options', [PasskeyController::class, 'requestOptions']); diff --git a/routes/cp.php b/routes/cp.php index 18578e90b99..e78115a704f 100644 --- a/routes/cp.php +++ b/routes/cp.php @@ -62,6 +62,8 @@ Route::middleware('craft.web')->group(function () { Route::get(CpAuthPath::Login->value, [LoginController::class, 'showLogin']); + Route::post(CpAuthPath::Login->value, [LoginController::class, 'attemptLogin']); + Route::get(CpAuthPath::Logout->value, [LoginController::class, 'logout']); Route::get(CpAuthPath::TwoFactorChallenge->value, [TwoFactorAuthenticationController::class, 'showForm']); Route::get(CpAuthPath::SetPassword->value, [SetPasswordController::class, 'show']); Route::post(CpAuthPath::SetPassword->value, [SetPasswordController::class, 'store']); @@ -76,8 +78,6 @@ Route::get('/', [DashboardController::class, 'redirect']); Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard'); - Route::get(CpAuthPath::Logout->value, [LoginController::class, 'logout']); - Route::get('utilities', [UtilitiesController::class, 'index']); // DeprecationErrors diff --git a/src/Cp/Cp.php b/src/Cp/Cp.php index fa21b7604af..594b8b64cde 100644 --- a/src/Cp/Cp.php +++ b/src/Cp/Cp.php @@ -24,11 +24,15 @@ public static function config(): Collection ->only([ 'cpTrigger', 'actionTrigger', - 'csrfTokenName', + 'cpLogoUrl', + 'useEmailAsUsername', + 'rememberedUserSessionDuration', + 'defaultCpLocale', ]) ->merge([ 'translations' => I18N::getAllTranslationsForLocale(app()->getLocale()) ?: new stdClass, - 'csrfTokenValue' => csrf_token(), + 'csrfTokenValue' => $config->enableCsrfProtection ? csrf_token() : null, + 'csrfTokenName' => $config->enableCsrfProtection ? $config->csrfTokenName : null, 'actionUrl' => Url::actionUrl(), 'cpUrl' => Url::cpUrl(), 'baseUrl' => Url::url(), diff --git a/src/Http/Controllers/Auth/LoginController.php b/src/Http/Controllers/Auth/LoginController.php index 2ee3cd1d45f..98d07c11da0 100644 --- a/src/Http/Controllers/Auth/LoginController.php +++ b/src/Http/Controllers/Auth/LoginController.php @@ -9,6 +9,7 @@ use CraftCms\Cms\Auth\Enums\CpAuthPath; use CraftCms\Cms\Auth\Impersonation; use CraftCms\Cms\Auth\UserProvider; +use CraftCms\Cms\Config\GeneralConfig; use CraftCms\Cms\View\HtmlStack; use CraftCms\Cms\View\TemplateMode; use Illuminate\Contracts\View\View; @@ -16,6 +17,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\URL; use Illuminate\Support\Timebox; +use Inertia\Inertia; use Symfony\Component\HttpFoundation\Response; use function CraftCms\Cms\cp_url; @@ -23,7 +25,7 @@ readonly class LoginController extends AuthenticationController { - public function showLogin(Request $request): Response|View + public function showLogin(Request $request, GeneralConfig $generalConfig, AuthMethods $authMethods): Response|View|\Inertia\Response { // see if they're already logged in if ($user = $request->user()) { @@ -35,7 +37,9 @@ public function showLogin(Request $request): Response|View return redirect()->action([TwoFactorAuthenticationController::class, 'showForm']); } - return $this->renderViewWithFallback('login'); + return Inertia::render('Login', [ + 'username' => $generalConfig->rememberUsernameDuration ? $authMethods->getRememberedUsername() : '', + ]); } /** @@ -111,6 +115,17 @@ public function attemptLogin(Request $request, AuthMethods $auth, Impersonation public function logout(Request $request): Response { + // If already logged out, just redirect to the appropriate destination + if (auth('craft')->guest()) { + if ($request->wantsJson()) { + return $this->asSuccess(); + } + + return $request->isCpRequest() + ? redirect(cp_url(CpAuthPath::Login->value)) + : redirect($this->generalConfig->getPostLogoutRedirect()); + } + auth('craft')->logout(); if ($request->wantsJson()) { diff --git a/src/Http/Middleware/HandleInertiaRequests.php b/src/Http/Middleware/HandleInertiaRequests.php index ab3911a9da4..1f3bf4e42e8 100644 --- a/src/Http/Middleware/HandleInertiaRequests.php +++ b/src/Http/Middleware/HandleInertiaRequests.php @@ -7,6 +7,7 @@ use CraftCms\Aliases\Aliases; use CraftCms\Cms\Cms; use CraftCms\Cms\Config\GeneralConfig; +use CraftCms\Cms\Cp\Cp; use CraftCms\Cms\Cp\Icons; use CraftCms\Cms\Cp\Navigation; use CraftCms\Cms\Database\Table; @@ -95,9 +96,7 @@ public function share(Request $request): array 'hasWaitingJobs' => false, ], 'craft' => fn () => [ - 'general' => [ - 'useEmailAsUsername' => $generalConfig->useEmailAsUsername, - ], + 'general' => Cp::config()->toArray(), 'system' => [ 'name' => Cms::systemName(), 'icon' => $systemIcon, @@ -109,13 +108,13 @@ public function share(Request $request): array 'site' => [ 'url' => $currentSite->getBaseUrl(), ], - 'currentUser' => [ - 'id' => $currentUser->id ?? null, - 'username' => $currentUser->username ?? null, - 'email' => $currentUser->email ?? null, - 'name' => $currentUser->name ?? null, + 'currentUser' => $currentUser ? [ + 'id' => $currentUser->id, + 'username' => $currentUser->username, + 'email' => $currentUser->email, + 'name' => $currentUser->name, 'thumbHtml' => $currentUser->getThumbHtml(30), - ], + ] : null, 'readOnly' => ! $generalConfig->allowAdminChanges, 'allowAdminChanges' => $generalConfig->allowAdminChanges, 'cpUrl' => cp_url(), From ee143ee1fec8b9cb228311e80905a87657f66cb4 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Wed, 6 May 2026 16:23:00 -0500 Subject: [PATCH 2/3] Initial inertia implementation --- resources/js/bootstrap/cp.ts | 5 ++ resources/js/components/ActionMenu.vue | 3 +- .../js/components/Auth/RecoveryCodesForm.vue | 36 ++++++++++ resources/js/components/Auth/TotpForm.vue | 33 +++++++++ .../js/components/Auth/TwoFactorForm.vue | 15 ++++ .../{pages/Login.vue => layout/AuthBase.vue} | 7 +- resources/js/pages/Auth/Challenge.vue | 71 +++++++++++++++++++ resources/js/pages/Auth/Login.vue | 18 +++++ src/Auth/AuthMethods.php | 3 +- src/Auth/Methods/RecoveryCodes.php | 9 ++- src/Auth/Methods/TOTP.php | 10 ++- src/Http/Controllers/Auth/LoginController.php | 2 +- .../TwoFactorAuthenticationController.php | 34 ++++++--- src/Http/Middleware/HandleInertiaRequests.php | 29 +++++++- src/Http/RespondsWithFlash.php | 2 +- 15 files changed, 252 insertions(+), 25 deletions(-) create mode 100644 resources/js/components/Auth/RecoveryCodesForm.vue create mode 100644 resources/js/components/Auth/TotpForm.vue create mode 100644 resources/js/components/Auth/TwoFactorForm.vue rename resources/js/{pages/Login.vue => layout/AuthBase.vue} (77%) create mode 100644 resources/js/pages/Auth/Challenge.vue create mode 100644 resources/js/pages/Auth/Login.vue diff --git a/resources/js/bootstrap/cp.ts b/resources/js/bootstrap/cp.ts index d00d006c032..ff656668067 100644 --- a/resources/js/bootstrap/cp.ts +++ b/resources/js/bootstrap/cp.ts @@ -16,6 +16,8 @@ import AssetIndexes from '@/components/utilities/AssetIndexes/AssetIndexes.vue'; import SystemMessages from '@/components/utilities/SystemMessages/SystemMessages.vue'; import DeprecationErrorsToolbar from '@/components/utilities/DeprecationErrors/DeprecationErrorsToolbar.vue'; import {setTranslations} from '@craftcms/cp/utilities/translate.ts.mjs'; +import TotpForm from '@/components/Auth/TotpForm.vue'; +import RecoveryCodesForm from '@/components/Auth/RecoveryCodesForm.vue'; let bootedCallbacks: Array<(instance: any) => void> = []; let bootingCallbacks: Array<(instance: any) => void> = []; @@ -97,6 +99,9 @@ const Cp = { app.component('ProjectConfig', ProjectConfig); app.component('AssetIndexes', AssetIndexes); app.component('SystemMessages', SystemMessages); + + app.component('TotpForm', TotpForm); + app.component('RecoveryCodesForm', RecoveryCodesForm); }, }); diff --git a/resources/js/components/ActionMenu.vue b/resources/js/components/ActionMenu.vue index 1abf7292395..1e85a4564dd 100644 --- a/resources/js/components/ActionMenu.vue +++ b/resources/js/components/ActionMenu.vue @@ -2,6 +2,7 @@ import {t} from '@craftcms/cp/utilities/translate.ts.mjs'; import type {VariantKey} from '@craftcms/cp/types/index.ts'; import {type Component, computed, type VNode} from 'vue'; + import VarDump from '@/components/VarDump.vue'; interface ActionItemHr { type: 'hr'; @@ -73,7 +74,7 @@