From 743e21c2d5f509638cb35ce55bf4d16f3d27e4d1 Mon Sep 17 00:00:00 2001 From: Derek Bourgeois Date: Tue, 24 Mar 2026 02:41:08 -0400 Subject: [PATCH 1/6] feat: add fortify authentication foundation --- AGENTS.md | 1 + CLAUDE.md | 1 + GEMINI.md | 1 + README.md | 15 + app/Actions/Fortify/CreateNewUser.php | 43 +++ .../Fortify/PasswordValidationRules.php | 19 ++ app/Actions/Fortify/ResetUserPassword.php | 32 ++ app/Actions/Fortify/UpdateUserPassword.php | 35 +++ .../Fortify/UpdateUserProfileInformation.php | 61 ++++ app/Providers/FortifyServiceProvider.php | 54 ++++ bootstrap/providers.php | 2 + composer.json | 1 + composer.lock | 291 +++++++++++++++++- config/fortify.php | 151 +++++++++ ..._add_two_factor_columns_to_users_table.php | 42 +++ .../views/auth/forgot-password.blade.php | 35 +++ resources/views/auth/layout.blade.php | 98 ++++++ resources/views/auth/login.blade.php | 55 ++++ resources/views/auth/register.blade.php | 71 +++++ resources/views/auth/reset-password.blade.php | 60 ++++ .../components/desktop/profile-menu.blade.php | 10 +- resources/views/welcome.blade.php | 12 +- routes/web.php | 2 +- tests/Feature/DesktopShellTest.php | 17 + tests/Feature/FortifyAuthenticationTest.php | 116 +++++++ 25 files changed, 1219 insertions(+), 6 deletions(-) create mode 100644 app/Actions/Fortify/CreateNewUser.php create mode 100644 app/Actions/Fortify/PasswordValidationRules.php create mode 100644 app/Actions/Fortify/ResetUserPassword.php create mode 100644 app/Actions/Fortify/UpdateUserPassword.php create mode 100644 app/Actions/Fortify/UpdateUserProfileInformation.php create mode 100644 app/Providers/FortifyServiceProvider.php create mode 100644 config/fortify.php create mode 100644 database/migrations/2026_03_24_063201_add_two_factor_columns_to_users_table.php create mode 100644 resources/views/auth/forgot-password.blade.php create mode 100644 resources/views/auth/layout.blade.php create mode 100644 resources/views/auth/login.blade.php create mode 100644 resources/views/auth/register.blade.php create mode 100644 resources/views/auth/reset-password.blade.php create mode 100644 tests/Feature/FortifyAuthenticationTest.php diff --git a/AGENTS.md b/AGENTS.md index 56d7dec..5e870a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ This application is a Laravel application and its main Laravel ecosystems packag - php - 8.4 - laravel/ai (AI) - v0 +- laravel/fortify (FORTIFY) - v1 - laravel/framework (LARAVEL) - v13 - laravel/pennant (PENNANT) - v1 - laravel/prompts (PROMPTS) - v0 diff --git a/CLAUDE.md b/CLAUDE.md index 56d7dec..5e870a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,7 @@ This application is a Laravel application and its main Laravel ecosystems packag - php - 8.4 - laravel/ai (AI) - v0 +- laravel/fortify (FORTIFY) - v1 - laravel/framework (LARAVEL) - v13 - laravel/pennant (PENNANT) - v1 - laravel/prompts (PROMPTS) - v0 diff --git a/GEMINI.md b/GEMINI.md index 56d7dec..5e870a5 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -11,6 +11,7 @@ This application is a Laravel application and its main Laravel ecosystems packag - php - 8.4 - laravel/ai (AI) - v0 +- laravel/fortify (FORTIFY) - v1 - laravel/framework (LARAVEL) - v13 - laravel/pennant (PENNANT) - v1 - laravel/prompts (PROMPTS) - v0 diff --git a/README.md b/README.md index f35aae9..3839d83 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,21 @@ composer native:dev That path installs dependencies, prepares the Laravel app, bootstraps NativePHP, and starts the local desktop development loop. +### Local Authentication + +Katra now uses Laravel Fortify for the first local authentication foundation. + +- Create a local account at `/register`, then sign in at `/login`. +- The desktop shell route at `/` is now authentication-protected. +- Password recovery is available through `/forgot-password`. +- Make sure your local database migrations are current before you try the auth flow: + +```bash +php artisan migrate --force +``` + +- If you are testing password reset locally and want to inspect the reset link without sending mail, use a local-safe mailer such as `MAIL_MAILER=log`. + ### Configure AI Providers The Laravel AI SDK is installed and its conversation storage migrations are part of the application now. diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php new file mode 100644 index 0000000..ee2d712 --- /dev/null +++ b/app/Actions/Fortify/CreateNewUser.php @@ -0,0 +1,43 @@ + $input + * + * @throws ValidationException + */ + public function create(array $input): User + { + Validator::make($input, [ + 'name' => ['required', 'string', 'max:255'], + 'email' => [ + 'required', + 'string', + 'email', + 'max:255', + Rule::unique(User::class), + ], + 'password' => $this->passwordRules(), + ])->validate(); + + return User::create([ + 'name' => $input['name'], + 'email' => $input['email'], + 'password' => Hash::make($input['password']), + ]); + } +} diff --git a/app/Actions/Fortify/PasswordValidationRules.php b/app/Actions/Fortify/PasswordValidationRules.php new file mode 100644 index 0000000..3678865 --- /dev/null +++ b/app/Actions/Fortify/PasswordValidationRules.php @@ -0,0 +1,19 @@ +|string> + */ + protected function passwordRules(): array + { + return ['required', 'string', Password::default(), 'confirmed']; + } +} diff --git a/app/Actions/Fortify/ResetUserPassword.php b/app/Actions/Fortify/ResetUserPassword.php new file mode 100644 index 0000000..667651f --- /dev/null +++ b/app/Actions/Fortify/ResetUserPassword.php @@ -0,0 +1,32 @@ + $input + * + * @throws ValidationException + */ + public function reset(User $user, array $input): void + { + Validator::make($input, [ + 'password' => $this->passwordRules(), + ])->validate(); + + $user->forceFill([ + 'password' => Hash::make($input['password']), + ])->save(); + } +} diff --git a/app/Actions/Fortify/UpdateUserPassword.php b/app/Actions/Fortify/UpdateUserPassword.php new file mode 100644 index 0000000..4a0306d --- /dev/null +++ b/app/Actions/Fortify/UpdateUserPassword.php @@ -0,0 +1,35 @@ + $input + * + * @throws ValidationException + */ + public function update(User $user, array $input): void + { + Validator::make($input, [ + 'current_password' => ['required', 'string', 'current_password:web'], + 'password' => $this->passwordRules(), + ], [ + 'current_password.current_password' => __('The provided password does not match your current password.'), + ])->validateWithBag('updatePassword'); + + $user->forceFill([ + 'password' => Hash::make($input['password']), + ])->save(); + } +} diff --git a/app/Actions/Fortify/UpdateUserProfileInformation.php b/app/Actions/Fortify/UpdateUserProfileInformation.php new file mode 100644 index 0000000..62f58fa --- /dev/null +++ b/app/Actions/Fortify/UpdateUserProfileInformation.php @@ -0,0 +1,61 @@ + $input + * + * @throws ValidationException + */ + public function update(User $user, array $input): void + { + Validator::make($input, [ + 'name' => ['required', 'string', 'max:255'], + + 'email' => [ + 'required', + 'string', + 'email', + 'max:255', + Rule::unique('users')->ignore($user->id), + ], + ])->validateWithBag('updateProfileInformation'); + + if ($input['email'] !== $user->email && + $user instanceof MustVerifyEmail) { + $this->updateVerifiedUser($user, $input); + } else { + $user->forceFill([ + 'name' => $input['name'], + 'email' => $input['email'], + ])->save(); + } + } + + /** + * Update the given verified user's profile information. + * + * @param array $input + */ + protected function updateVerifiedUser(User $user, array $input): void + { + $user->forceFill([ + 'name' => $input['name'], + 'email' => $input['email'], + 'email_verified_at' => null, + ])->save(); + + $user->sendEmailVerificationNotification(); + } +} diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php new file mode 100644 index 0000000..edbc377 --- /dev/null +++ b/app/Providers/FortifyServiceProvider.php @@ -0,0 +1,54 @@ + view('auth.login')); + Fortify::registerView(fn () => view('auth.register')); + Fortify::requestPasswordResetLinkView(fn () => view('auth.forgot-password')); + Fortify::resetPasswordView(fn (Request $request) => view('auth.reset-password', [ + 'request' => $request, + ])); + + RateLimiter::for('login', function (Request $request) { + $throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip()); + + return Limit::perMinute(5)->by($throttleKey); + }); + + RateLimiter::for('two-factor', function (Request $request) { + return Limit::perMinute(5)->by($request->session()->get('login.id')); + }); + } +} diff --git a/bootstrap/providers.php b/bootstrap/providers.php index fc94ae6..5ffd769 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -1,7 +1,9 @@ =7.1 <9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "DASPRiD\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "PHP 7.1 enum implementation", + "keywords": [ + "enum", + "map" + ], + "support": { + "issues": "https://github.com/DASPRiD/Enum/issues", + "source": "https://github.com/DASPRiD/Enum/tree/1.0.7" + }, + "time": "2025-09-16T12:23:56+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -1200,6 +1305,69 @@ }, "time": "2026-03-18T14:44:36+00:00" }, + { + "name": "laravel/fortify", + "version": "v1.36.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/fortify.git", + "reference": "cad8bfeb63f6818f173d40090725c565c92651d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/fortify/zipball/cad8bfeb63f6818f173d40090725c565c92651d4", + "reference": "cad8bfeb63f6818f173d40090725c565c92651d4", + "shasum": "" + }, + "require": { + "bacon/bacon-qr-code": "^3.0", + "ext-json": "*", + "illuminate/console": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "php": "^8.1", + "pragmarx/google2fa": "^9.0" + }, + "require-dev": { + "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Fortify\\FortifyServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Fortify\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Backend controllers and scaffolding for Laravel authentication.", + "keywords": [ + "auth", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/fortify/issues", + "source": "https://github.com/laravel/fortify" + }, + "time": "2026-03-10T19:59:49+00:00" + }, { "name": "laravel/framework", "version": "v13.1.1", @@ -2951,6 +3119,75 @@ ], "time": "2026-02-16T23:10:27+00:00" }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.5", @@ -3096,6 +3333,58 @@ ], "time": "2023-01-30T09:18:47+00:00" }, + { + "name": "pragmarx/google2fa", + "version": "v9.0.0", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/google2fa.git", + "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/e6bc62dd6ae83acc475f57912e27466019a1f2cf", + "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1.0|^2.0|^3.0", + "php": "^7.1|^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.9", + "phpunit/phpunit": "^7.5.15|^8.5|^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "PragmaRX\\Google2FA\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "role": "Creator & Designer" + } + ], + "description": "A One Time Password Authentication package, compatible with Google Authenticator.", + "keywords": [ + "2fa", + "Authentication", + "Two Factor Authentication", + "google2fa" + ], + "support": { + "issues": "https://github.com/antonioribeiro/google2fa/issues", + "source": "https://github.com/antonioribeiro/google2fa/tree/v9.0.0" + }, + "time": "2025-09-19T22:51:08+00:00" + }, { "name": "prism-php/prism", "version": "v0.99.22", diff --git a/config/fortify.php b/config/fortify.php new file mode 100644 index 0000000..474fce2 --- /dev/null +++ b/config/fortify.php @@ -0,0 +1,151 @@ + 'web', + + /* + |-------------------------------------------------------------------------- + | Fortify Password Broker + |-------------------------------------------------------------------------- + | + | Here you may specify which password broker Fortify can use when a user + | is resetting their password. This configured value should match one + | of your password brokers setup in your "auth" configuration file. + | + */ + + 'passwords' => 'users', + + /* + |-------------------------------------------------------------------------- + | Username / Email + |-------------------------------------------------------------------------- + | + | This value defines which model attribute should be considered as your + | application's "username" field. Typically, this might be the email + | address of the users but you are free to change this value here. + | + | Out of the box, Fortify expects forgot password and reset password + | requests to have a field named 'email'. If the application uses + | another name for the field you may define it below as needed. + | + */ + + 'username' => 'email', + + 'email' => 'email', + + /* + |-------------------------------------------------------------------------- + | Lowercase Usernames + |-------------------------------------------------------------------------- + | + | This value defines whether usernames should be lowercased before saving + | them in the database, as some database system string fields are case + | sensitive. You may disable this for your application if necessary. + | + */ + + 'lowercase_usernames' => true, + + /* + |-------------------------------------------------------------------------- + | Home Path + |-------------------------------------------------------------------------- + | + | Here you may configure the path where users will get redirected during + | authentication or password reset when the operations are successful + | and the user is authenticated. You are free to change this value. + | + */ + + 'home' => '/', + + /* + |-------------------------------------------------------------------------- + | Fortify Routes Prefix / Subdomain + |-------------------------------------------------------------------------- + | + | Here you may specify which prefix Fortify will assign to all the routes + | that it registers with the application. If necessary, you may change + | subdomain under which all of the Fortify routes will be available. + | + */ + + 'prefix' => '', + + 'domain' => null, + + /* + |-------------------------------------------------------------------------- + | Fortify Routes Middleware + |-------------------------------------------------------------------------- + | + | Here you may specify which middleware Fortify will assign to the routes + | that it registers with the application. If necessary, you may change + | these middleware but typically this provided default is preferred. + | + */ + + 'middleware' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Rate Limiting + |-------------------------------------------------------------------------- + | + | By default, Fortify will throttle logins to five requests per minute for + | every email and IP address combination. However, if you would like to + | specify a custom rate limiter to call then you may specify it here. + | + */ + + 'limiters' => [ + 'login' => 'login', + 'two-factor' => 'two-factor', + ], + + /* + |-------------------------------------------------------------------------- + | Register View Routes + |-------------------------------------------------------------------------- + | + | Here you may specify if the routes returning views should be disabled as + | you may not need them when building your own application. This may be + | especially true if you're writing a custom single-page application. + | + */ + + 'views' => true, + + /* + |-------------------------------------------------------------------------- + | Features + |-------------------------------------------------------------------------- + | + | Some of the Fortify features are optional. You may disable the features + | by removing them from this array. You're free to only remove some of + | these features or you can even remove all of these if you need to. + | + */ + + 'features' => [ + Features::registration(), + Features::resetPasswords(), + ], + +]; diff --git a/database/migrations/2026_03_24_063201_add_two_factor_columns_to_users_table.php b/database/migrations/2026_03_24_063201_add_two_factor_columns_to_users_table.php new file mode 100644 index 0000000..45739ef --- /dev/null +++ b/database/migrations/2026_03_24_063201_add_two_factor_columns_to_users_table.php @@ -0,0 +1,42 @@ +text('two_factor_secret') + ->after('password') + ->nullable(); + + $table->text('two_factor_recovery_codes') + ->after('two_factor_secret') + ->nullable(); + + $table->timestamp('two_factor_confirmed_at') + ->after('two_factor_recovery_codes') + ->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn([ + 'two_factor_secret', + 'two_factor_recovery_codes', + 'two_factor_confirmed_at', + ]); + }); + } +}; diff --git a/resources/views/auth/forgot-password.blade.php b/resources/views/auth/forgot-password.blade.php new file mode 100644 index 0000000..6331577 --- /dev/null +++ b/resources/views/auth/forgot-password.blade.php @@ -0,0 +1,35 @@ +@extends('auth.layout') + +@section('title', 'Reset your Katra password') +@section('heading', 'Reset your password') +@section('copy', 'Request a reset link for your local Katra account.') +@section('panel_copy', 'We will send a password reset link to the email address tied to this local account.') + +@section('content') +
+ @csrf + +
+ + +
+ + +
+ +

+ Remembered your password? + Back to sign in +

+@endsection diff --git a/resources/views/auth/layout.blade.php b/resources/views/auth/layout.blade.php new file mode 100644 index 0000000..d3eda29 --- /dev/null +++ b/resources/views/auth/layout.blade.php @@ -0,0 +1,98 @@ + + + + + + + @yield('title', config('app.name', 'Katra')) + + + + + @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) + @vite(['resources/css/app.css', 'resources/js/app.js']) + @endif + + +
+ + +
+
+
+ Katra + Katra +
+

@yield('mobile_eyebrow', 'Authentication')

+

@yield('heading')

+

@yield('copy')

+
+
+ +
+ @if (session('status')) +
+ {{ session('status') }} +
+ @endif + + @if ($errors->any()) +
+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + + + + @yield('content') +
+
+
+
+ + diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php new file mode 100644 index 0000000..976bb94 --- /dev/null +++ b/resources/views/auth/login.blade.php @@ -0,0 +1,55 @@ +@extends('auth.layout') + +@section('title', 'Sign in to Katra') +@section('heading', 'Sign in to Katra') +@section('copy', 'Use your local Katra account to open the desktop workspace.') +@section('panel_copy', 'Sign in with the local account for this instance. Password reset stays available if you need to recover access.') + +@section('content') +
+ @csrf + +
+ + +
+ +
+
+ + Forgot password? +
+ +
+ + + + +
+ +

+ Need a local account? + Create one now +

+@endsection diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php new file mode 100644 index 0000000..61b3a3c --- /dev/null +++ b/resources/views/auth/register.blade.php @@ -0,0 +1,71 @@ +@extends('auth.layout') + +@section('title', 'Create your Katra account') +@section('heading', 'Create your Katra account') +@section('copy', 'Set up the local account that unlocks the Katra desktop workspace.') +@section('panel_copy', 'This keeps the first auth step small and local while we prepare the broader connection and workspace model.') + +@section('content') +
+ @csrf + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +

+ Already have an account? + Sign in instead +

+@endsection diff --git a/resources/views/auth/reset-password.blade.php b/resources/views/auth/reset-password.blade.php new file mode 100644 index 0000000..599fecd --- /dev/null +++ b/resources/views/auth/reset-password.blade.php @@ -0,0 +1,60 @@ +@extends('auth.layout') + +@section('title', 'Choose a new Katra password') +@section('heading', 'Choose a new password') +@section('copy', 'Finish recovering your local Katra account.') +@section('panel_copy', 'Set a new password for this account, then Fortify will send you straight back into Katra.') + +@section('content') +
+ @csrf + + + +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +

+ Want to try signing in again? + Back to sign in +

+@endsection diff --git a/resources/views/components/desktop/profile-menu.blade.php b/resources/views/components/desktop/profile-menu.blade.php index eefc340..a14e737 100644 --- a/resources/views/components/desktop/profile-menu.blade.php +++ b/resources/views/components/desktop/profile-menu.blade.php @@ -68,9 +68,13 @@ - +
+ @csrf + + +
diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php index db4c4f0..83a89c1 100644 --- a/resources/views/welcome.blade.php +++ b/resources/views/welcome.blade.php @@ -81,6 +81,16 @@ ]; @endphp
+ @php + $viewer = request()->user(); + $viewerName = $viewer?->name ?? 'Derek Bourgeois'; + $viewerEmail = $viewer?->email ?? 'derek@katra.io'; + $viewerInitials = collect(explode(' ', $viewerName)) + ->filter() + ->take(2) + ->map(fn (string $segment): string => strtoupper(substr($segment, 0, 1))) + ->implode(''); + @endphp
- +
diff --git a/routes/web.php b/routes/web.php index 8e01cc8..6b174d7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,4 +3,4 @@ use App\Http\Controllers\HomeController; use Illuminate\Support\Facades\Route; -Route::get('/', HomeController::class)->name('home'); +Route::middleware('auth')->get('/', HomeController::class)->name('home'); diff --git a/tests/Feature/DesktopShellTest.php b/tests/Feature/DesktopShellTest.php index 8be23f8..81ce659 100644 --- a/tests/Feature/DesktopShellTest.php +++ b/tests/Feature/DesktopShellTest.php @@ -1,5 +1,16 @@ make([ + 'id' => 1, + 'name' => 'Derek Bourgeois', + 'email' => 'derek@katra.io', + ]); +} + test('the desktop shell exposes the katra bootstrap screen', function () { config()->set('pennant.default', 'array'); config()->set('surreal.autostart', false); @@ -8,6 +19,8 @@ config()->set('surreal.endpoint', 'ws://127.0.0.1:18999'); config()->set('surreal.binary', 'surreal-missing-binary-for-desktop-shell-test'); + $this->actingAs(desktopShellUser()); + $this->get('/') ->assertSuccessful() ->assertSee('Katra') @@ -97,6 +110,8 @@ config()->set('surreal.endpoint', 'ws://127.0.0.1:18999'); config()->set('surreal.binary', 'surreal-missing-binary-for-desktop-shell-test'); + $this->actingAs(desktopShellUser()); + $this->get('/') ->assertSuccessful() ->assertSee('Katra') @@ -112,6 +127,8 @@ config()->set('surreal.endpoint', 'ws://127.0.0.1:18999'); config()->set('surreal.binary', 'surreal-missing-binary-for-desktop-shell-test'); + $this->actingAs(desktopShellUser()); + $this->get('/?workspace=design-lab') ->assertSuccessful() ->assertSee('Design Lab') diff --git a/tests/Feature/FortifyAuthenticationTest.php b/tests/Feature/FortifyAuthenticationTest.php new file mode 100644 index 0000000..87e9b0f --- /dev/null +++ b/tests/Feature/FortifyAuthenticationTest.php @@ -0,0 +1,116 @@ +get(route('home')) + ->assertRedirect(route('login')); +}); + +test('the fortify auth screens render', function () { + $this->get(route('login')) + ->assertSuccessful() + ->assertSee('Sign in to Katra') + ->assertSee('Forgot password?'); + + $this->get(route('register')) + ->assertSuccessful() + ->assertSee('Create your Katra account'); + + $this->get(route('password.request')) + ->assertSuccessful() + ->assertSee('Reset your password'); +}); + +test('a user can register for a local katra account', function () { + $this->post(route('register'), [ + 'name' => 'Derek Bourgeois', + 'email' => 'derek@katra.io', + 'password' => 'password', + 'password_confirmation' => 'password', + ])->assertRedirect(route('home')); + + $this->assertAuthenticated(); + + expect(User::query()->where('email', 'derek@katra.io')->exists())->toBeTrue(); +}); + +test('a user can sign in with valid credentials', function () { + $user = User::factory()->create([ + 'email' => 'derek@katra.io', + 'password' => 'password', + ]); + + $this->post(route('login'), [ + 'email' => $user->email, + 'password' => 'password', + ])->assertRedirect(route('home')); + + $this->assertAuthenticatedAs($user); +}); + +test('a user cannot sign in with invalid credentials', function () { + $user = User::factory()->create([ + 'email' => 'derek@katra.io', + 'password' => 'password', + ]); + + $this->from(route('login')) + ->post(route('login'), [ + 'email' => $user->email, + 'password' => 'not-the-right-password', + ]) + ->assertRedirect(route('login')) + ->assertSessionHasErrors('email'); + + $this->assertGuest(); +}); + +test('an authenticated user can log out from katra', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->post(route('logout')) + ->assertRedirect('/'); + + $this->assertGuest(); +}); + +test('a user can request a password reset link', function () { + Notification::fake(); + + $user = User::factory()->create([ + 'email' => 'derek@katra.io', + ]); + + $this->post(route('password.email'), [ + 'email' => $user->email, + ])->assertSessionHas('status'); + + Notification::assertSentTo($user, ResetPassword::class); +}); + +test('a user can reset their password with a valid token', function () { + $user = User::factory()->create([ + 'email' => 'derek@katra.io', + 'password' => 'password', + ]); + + $token = Password::broker()->createToken($user); + + $this->post(route('password.update'), [ + 'token' => $token, + 'email' => $user->email, + 'password' => 'new-password', + 'password_confirmation' => 'new-password', + ])->assertSessionHasNoErrors(); + + expect(Hash::check('new-password', $user->fresh()->password))->toBeTrue(); +}); From 4659684b506e9fd3053a442fcec54edc6830b164 Mon Sep 17 00:00:00 2001 From: Derek Bourgeois Date: Tue, 24 Mar 2026 02:51:53 -0400 Subject: [PATCH 2/6] fix: neutralize fortify auth flow --- README.md | 6 +- app/Actions/Fortify/CreateNewUser.php | 7 +- .../Fortify/UpdateUserProfileInformation.php | 11 ++- app/Models/User.php | 17 +++- database/factories/UserFactory.php | 7 +- ...dd_profile_name_columns_to_users_table.php | 29 ++++++ resources/views/auth/connect-server.blade.php | 61 ++++++++++++ .../views/auth/forgot-password.blade.php | 3 +- resources/views/auth/layout.blade.php | 98 ++++++------------- resources/views/auth/login.blade.php | 15 ++- resources/views/auth/register.blade.php | 40 +++++--- resources/views/auth/reset-password.blade.php | 3 +- routes/web.php | 2 + tests/Feature/DesktopUiFeatureFlagTest.php | 17 ++++ tests/Feature/ExampleTest.php | 7 +- tests/Feature/FortifyAuthenticationTest.php | 24 ++++- 16 files changed, 239 insertions(+), 108 deletions(-) create mode 100644 database/migrations/2026_03_24_064850_add_profile_name_columns_to_users_table.php create mode 100644 resources/views/auth/connect-server.blade.php diff --git a/README.md b/README.md index 3839d83..7e97057 100644 --- a/README.md +++ b/README.md @@ -66,11 +66,11 @@ composer native:dev That path installs dependencies, prepares the Laravel app, bootstraps NativePHP, and starts the local desktop development loop. -### Local Authentication +### Authentication -Katra now uses Laravel Fortify for the first local authentication foundation. +Katra now uses Laravel Fortify for the first authentication foundation. -- Create a local account at `/register`, then sign in at `/login`. +- Create an account at `/register`, then sign in at `/login`. - The desktop shell route at `/` is now authentication-protected. - Password recovery is available through `/forgot-password`. - Make sure your local database migrations are current before you try the auth flow: diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index ee2d712..f9a8d69 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -23,7 +23,8 @@ class CreateNewUser implements CreatesNewUsers public function create(array $input): User { Validator::make($input, [ - 'name' => ['required', 'string', 'max:255'], + 'first_name' => ['required', 'string', 'max:255'], + 'last_name' => ['required', 'string', 'max:255'], 'email' => [ 'required', 'string', @@ -35,7 +36,9 @@ public function create(array $input): User ])->validate(); return User::create([ - 'name' => $input['name'], + 'first_name' => $input['first_name'], + 'last_name' => $input['last_name'], + 'name' => trim($input['first_name'].' '.$input['last_name']), 'email' => $input['email'], 'password' => Hash::make($input['password']), ]); diff --git a/app/Actions/Fortify/UpdateUserProfileInformation.php b/app/Actions/Fortify/UpdateUserProfileInformation.php index 62f58fa..20c8263 100644 --- a/app/Actions/Fortify/UpdateUserProfileInformation.php +++ b/app/Actions/Fortify/UpdateUserProfileInformation.php @@ -21,7 +21,8 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation public function update(User $user, array $input): void { Validator::make($input, [ - 'name' => ['required', 'string', 'max:255'], + 'first_name' => ['required', 'string', 'max:255'], + 'last_name' => ['required', 'string', 'max:255'], 'email' => [ 'required', @@ -37,7 +38,9 @@ public function update(User $user, array $input): void $this->updateVerifiedUser($user, $input); } else { $user->forceFill([ - 'name' => $input['name'], + 'first_name' => $input['first_name'], + 'last_name' => $input['last_name'], + 'name' => trim($input['first_name'].' '.$input['last_name']), 'email' => $input['email'], ])->save(); } @@ -51,7 +54,9 @@ public function update(User $user, array $input): void protected function updateVerifiedUser(User $user, array $input): void { $user->forceFill([ - 'name' => $input['name'], + 'first_name' => $input['first_name'], + 'last_name' => $input['last_name'], + 'name' => trim($input['first_name'].' '.$input['last_name']), 'email' => $input['email'], 'email_verified_at' => null, ])->save(); diff --git a/app/Models/User.php b/app/Models/User.php index f6ba1d2..34473d5 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -6,11 +6,12 @@ use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; -#[Fillable(['name', 'email', 'password'])] +#[Fillable(['first_name', 'last_name', 'name', 'email', 'password'])] #[Hidden(['password', 'remember_token'])] class User extends Authenticatable { @@ -29,4 +30,18 @@ protected function casts(): array 'password' => 'hashed', ]; } + + protected function name(): Attribute + { + return Attribute::make( + get: function (?string $value, array $attributes): string { + $name = trim(implode(' ', array_filter([ + $attributes['first_name'] ?? null, + $attributes['last_name'] ?? null, + ]))); + + return $name !== '' ? $name : ($value ?? ''); + }, + ); + } } diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index c4ceb07..f9e63fb 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -24,8 +24,13 @@ class UserFactory extends Factory */ public function definition(): array { + $firstName = fake()->firstName(); + $lastName = fake()->lastName(); + return [ - 'name' => fake()->name(), + 'first_name' => $firstName, + 'last_name' => $lastName, + 'name' => trim($firstName.' '.$lastName), 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), diff --git a/database/migrations/2026_03_24_064850_add_profile_name_columns_to_users_table.php b/database/migrations/2026_03_24_064850_add_profile_name_columns_to_users_table.php new file mode 100644 index 0000000..2637557 --- /dev/null +++ b/database/migrations/2026_03_24_064850_add_profile_name_columns_to_users_table.php @@ -0,0 +1,29 @@ +string('first_name')->nullable()->after('id'); + $table->string('last_name')->nullable()->after('first_name'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['first_name', 'last_name']); + }); + } +}; diff --git a/resources/views/auth/connect-server.blade.php b/resources/views/auth/connect-server.blade.php new file mode 100644 index 0000000..e1a2292 --- /dev/null +++ b/resources/views/auth/connect-server.blade.php @@ -0,0 +1,61 @@ +@extends('auth.layout') + +@section('title', 'Connect to a Katra server') +@section('heading', 'Connect to a server') +@section('copy', 'Use your server account when you want to sign in to a remote Katra instance.') +@section('account_selector') + +@endsection + +@section('content') +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + +

+ Remote connection profiles are next. This view keeps the account choice visible now so the shared auth experience does not overfit the desktop-only path. +

+
+@endsection diff --git a/resources/views/auth/forgot-password.blade.php b/resources/views/auth/forgot-password.blade.php index 6331577..cfc4cef 100644 --- a/resources/views/auth/forgot-password.blade.php +++ b/resources/views/auth/forgot-password.blade.php @@ -2,8 +2,7 @@ @section('title', 'Reset your Katra password') @section('heading', 'Reset your password') -@section('copy', 'Request a reset link for your local Katra account.') -@section('panel_copy', 'We will send a password reset link to the email address tied to this local account.') +@section('copy', 'Request a reset link for your Katra account.') @section('content')
diff --git a/resources/views/auth/layout.blade.php b/resources/views/auth/layout.blade.php index d3eda29..584ec7e 100644 --- a/resources/views/auth/layout.blade.php +++ b/resources/views/auth/layout.blade.php @@ -14,83 +14,43 @@ @endif -
-