diff --git a/.env.example b/.env.example index 26d7cac..cca73db 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,7 @@ FILESYSTEM_DISK=local QUEUE_CONNECTION=database CACHE_STORE=database +CACHE_LIMITER=file # CACHE_PREFIX= MEMCACHED_HOST=127.0.0.1 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..600fdcb 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. +### Authentication + +Katra now uses Laravel Fortify for the first authentication foundation. + +- 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: + +```bash +php artisan migrate +``` + +- 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..f9a8d69 --- /dev/null +++ b/app/Actions/Fortify/CreateNewUser.php @@ -0,0 +1,46 @@ + $input + * + * @throws ValidationException + */ + public function create(array $input): User + { + Validator::make($input, [ + 'first_name' => ['required', 'string', 'max:255'], + 'last_name' => ['required', 'string', 'max:255'], + 'email' => [ + 'required', + 'string', + 'email', + 'max:255', + Rule::unique(User::class), + ], + 'password' => $this->passwordRules(), + ])->validate(); + + return User::create([ + '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/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..20c8263 --- /dev/null +++ b/app/Actions/Fortify/UpdateUserProfileInformation.php @@ -0,0 +1,66 @@ + $input + * + * @throws ValidationException + */ + public function update(User $user, array $input): void + { + Validator::make($input, [ + 'first_name' => ['required', 'string', 'max:255'], + 'last_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([ + 'first_name' => $input['first_name'], + 'last_name' => $input['last_name'], + 'name' => trim($input['first_name'].' '.$input['last_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([ + '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(); + + $user->sendEmailVerificationNotification(); + } +} 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/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/app/Services/Surreal/Query/SurrealQueryBuilder.php b/app/Services/Surreal/Query/SurrealQueryBuilder.php index 861e36e..d6ac432 100644 --- a/app/Services/Surreal/Query/SurrealQueryBuilder.php +++ b/app/Services/Surreal/Query/SurrealQueryBuilder.php @@ -7,6 +7,7 @@ use Illuminate\Database\Query\Builder; use Illuminate\Support\Arr; use Illuminate\Support\Collection; +use InvalidArgumentException; use RuntimeException; use stdClass; @@ -53,6 +54,21 @@ public function insert(array $values): bool return true; } + public function insertOrIgnore(array $values): int + { + $records = $this->prepareInsertValues($values); + $inserted = 0; + + foreach ($records as $record) { + $inserted += $this->surrealConnection()->insertOrIgnoreRecord( + table: (string) $this->from, + values: $record, + ) ? 1 : 0; + } + + return $inserted; + } + public function insertGetId(array $values, $sequence = null): int|string { return $this->surrealConnection()->insertRecordAndReturnId( @@ -92,6 +108,39 @@ public function delete($id = null): int ); } + public function upsert(array $values, array|string $uniqueBy, ?array $update = null): int + { + if ($uniqueBy === [] || $uniqueBy === '') { + throw new InvalidArgumentException('The unique columns must not be empty.'); + } + + $records = $this->prepareInsertValues($values); + + if ($records === []) { + return 0; + } + + $uniqueColumns = array_values(Arr::wrap($uniqueBy)); + + if ($update === []) { + return $this->insertOrIgnore($values); + } + + $updateColumns = $update ?? array_keys($records[0]); + $affected = 0; + + foreach ($records as $record) { + $affected += $this->surrealConnection()->upsertRecord( + table: (string) $this->from, + values: $record, + uniqueBy: $uniqueColumns, + updateColumns: array_values($updateColumns), + ) ? 1 : 0; + } + + return $affected; + } + public function exists(): bool { $query = clone $this; diff --git a/app/Services/Surreal/Schema/SurrealSchemaConnection.php b/app/Services/Surreal/Schema/SurrealSchemaConnection.php index a80e184..d1fd438 100644 --- a/app/Services/Surreal/Schema/SurrealSchemaConnection.php +++ b/app/Services/Surreal/Schema/SurrealSchemaConnection.php @@ -6,6 +6,7 @@ use App\Services\Surreal\SurrealCliClient; use App\Services\Surreal\SurrealConnection; use App\Services\Surreal\SurrealHttpClient; +use App\Services\Surreal\SurrealQueryException; use App\Services\Surreal\SurrealRuntimeManager; use Carbon\CarbonImmutable; use Closure; @@ -247,7 +248,7 @@ public function selectRecords(string $table, array $columns, array $wheres = [], */ public function insertRecord(string $table, array $values): array { - $key = $values['id'] ?? $this->nextKey($table); + $key = $this->keyForInsert($table, $values); return $this->createRecord( table: $table, @@ -258,7 +259,7 @@ public function insertRecord(string $table, array $values): array public function insertRecordAndReturnId(string $table, array $values, string $keyName = 'id'): int|string { - $key = $values[$keyName] ?? $this->nextKey($table); + $key = $values[$keyName] ?? $this->keyForInsert($table, $values); $this->createRecord( table: $table, @@ -269,6 +270,65 @@ public function insertRecordAndReturnId(string $table, array $values, string $ke return $key; } + /** + * @param array $values + */ + public function insertOrIgnoreRecord(string $table, array $values): bool + { + try { + $this->insertRecord($table, $values); + + return true; + } catch (SurrealQueryException $exception) { + if ($exception->isDuplicateRecord()) { + return false; + } + + throw $exception; + } + } + + /** + * @param array $values + * @param list $uniqueBy + * @param list $updateColumns + */ + public function upsertRecord(string $table, array $values, array $uniqueBy, array $updateColumns): bool + { + $match = Arr::only($values, $uniqueBy); + + if (count($match) !== count($uniqueBy)) { + throw new RuntimeException(sprintf( + 'Unable to upsert into [%s] because one or more unique columns are missing from the payload.', + $table, + )); + } + + $existingRecordId = $this->firstMatchingRecordId($table, $match); + + if ($existingRecordId === null) { + $this->insertRecord($table, $values); + + return true; + } + + $updatePayload = Arr::only($values, $updateColumns); + + if ($updatePayload === []) { + return true; + } + + $this->updateRecords($table, $updatePayload, [[ + 'type' => 'Basic', + 'column' => 'id', + 'operator' => '=', + 'value' => $existingRecordId, + 'boolean' => 'and', + ]]); + + return true; + } + /** * @param array $values * @param array> $wheres @@ -324,7 +384,28 @@ public function deleteRecords(string $table, array $wheres = [], ?int $limit = n $whereClause = $this->compileWhereClause($table, $wheres); if ($whereClause === null) { - throw new RuntimeException('Surreal deletes without a where clause are not supported by this driver yet.'); + if ($limit === null) { + $deletedRecords = $this->countRecords($table); + + if ($deletedRecords === 0) { + return 0; + } + + $this->runSurrealQuery(sprintf( + 'DELETE %s RETURN NONE;', + $this->normalizeIdentifier($table), + )); + + return $deletedRecords; + } + + $query = sprintf( + 'DELETE %s%s;', + $this->normalizeIdentifier($table), + $limit !== null ? ' LIMIT '.max(0, $limit) : '', + ); + + return count($this->normalizeRecordSet(Arr::get($this->runSurrealQuery($query), '0', []), $table)); } $query = sprintf( @@ -337,6 +418,23 @@ public function deleteRecords(string $table, array $wheres = [], ?int $limit = n return count($this->normalizeRecordSet(Arr::get($this->runSurrealQuery($query), '0', []), $table)); } + private function countRecords(string $table): int + { + $rows = $this->normalizeRecordSet( + Arr::get( + $this->runSurrealQuery(sprintf( + 'SELECT count() AS aggregate FROM %s GROUP ALL;', + $this->normalizeIdentifier($table), + )), + '0', + [], + ), + columns: ['aggregate'], + ); + + return (int) ($rows[0]['aggregate'] ?? 0); + } + protected function getDefaultSchemaGrammar(): SurrealSchemaGrammar { return new SurrealSchemaGrammar($this); @@ -658,6 +756,53 @@ private function nextKey(string $table): int return (int) $result; } + /** + * @param array $values + */ + private function keyForInsert(string $table, array $values): int|string + { + if (array_key_exists('id', $values)) { + return $values['id']; + } + + if (array_key_exists('key', $values) && is_scalar($values['key'])) { + return (string) $values['key']; + } + + return $this->nextKey($table); + } + + /** + * @param array $values + */ + private function firstMatchingRecordId(string $table, array $values): int|string|null + { + $wheres = array_map( + static fn (string $column, mixed $value): array => [ + 'type' => 'Basic', + 'column' => $column, + 'operator' => '=', + 'value' => $value, + 'boolean' => 'and', + ], + array_keys($values), + array_values($values), + ); + + $record = $this->selectRecords( + table: $table, + columns: ['id'], + wheres: $wheres, + limit: 1, + )[0] ?? null; + + if (! is_array($record) || ! array_key_exists('id', $record)) { + return null; + } + + return $record['id']; + } + private function normalizeIdentifier(string $identifier): string { if (! preg_match('/^[A-Za-z0-9_]+$/', $identifier)) { @@ -680,7 +825,7 @@ private function normalizeRecordIdentifier(string $table, mixed $value): string return $value; } - if (is_string($value) && preg_match('/^[A-Za-z0-9_-]+$/', $value)) { + if (is_string($value) && $value !== '') { return sprintf('%s:%s', $table, $value); } diff --git a/app/Services/Surreal/SurrealQueryException.php b/app/Services/Surreal/SurrealQueryException.php index 369d44d..c18cb48 100644 --- a/app/Services/Surreal/SurrealQueryException.php +++ b/app/Services/Surreal/SurrealQueryException.php @@ -33,4 +33,20 @@ public function isTableMissing(string $table): bool return false; } + + public function isDuplicateRecord(): bool + { + if (is_string($this->codeName) && strcasecmp($this->codeName, 'DUPLICATE') === 0) { + return true; + } + + if (! is_string($this->details)) { + return false; + } + + $normalizedDetails = strtolower($this->details); + + return str_contains($normalizedDetails, 'already exists') + || str_contains($normalizedDetails, 'duplicate'); + } } 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/cache.php b/config/cache.php index c68acdf..b31d488 100644 --- a/config/cache.php +++ b/config/cache.php @@ -17,6 +17,20 @@ 'default' => env('CACHE_STORE', 'database'), + /* + |-------------------------------------------------------------------------- + | Rate Limiter Store + |-------------------------------------------------------------------------- + | + | Authentication and other throttle middleware should use a cache store + | that supports Laravel's atomic limiter operations. The database cache + | driver assumes SQL transaction semantics, so Katra defaults limiters to + | the file store even when the main cache store is database-backed. + | + */ + + 'limiter' => env('CACHE_LIMITER', 'file'), + /* |-------------------------------------------------------------------------- | Cache Stores 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/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_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/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..5ddd3c6 --- /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 new file mode 100644 index 0000000..3921e5d --- /dev/null +++ b/resources/views/auth/forgot-password.blade.php @@ -0,0 +1,34 @@ +@extends('auth.layout') + +@section('title', 'Reset your Katra password') +@section('heading', 'Reset your password') +@section('copy', 'Request a reset link for your Katra 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..52c9562 --- /dev/null +++ b/resources/views/auth/layout.blade.php @@ -0,0 +1,58 @@ + + + + + + + @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 +
+ +
+ @hasSection('account_selector') +
+ @yield('account_selector') +
+ @endif + + @if (session('status')) +
+ {{ session('status') }} +
+ @endif + + @if ($errors->any()) +
+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + +
+

@yield('eyebrow', 'Authentication')

+

@yield('heading')

+

@yield('copy')

+
+ + @yield('content') +
+
+
+ + diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php new file mode 100644 index 0000000..e9df1c6 --- /dev/null +++ b/resources/views/auth/login.blade.php @@ -0,0 +1,64 @@ +@extends('auth.layout') + +@section('title', 'Sign in to Katra') +@section('heading', 'Sign in to Katra') +@section('copy', 'Choose how you want to sign in and continue into Katra.') +@section('account_selector') + +@endsection + +@section('content') +
+ @csrf + +
+ + +
+ +
+
+ + Forgot password? +
+ +
+ + + + +
+ +

+ Need an 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..52c6ef9 --- /dev/null +++ b/resources/views/auth/register.blade.php @@ -0,0 +1,85 @@ +@extends('auth.layout') + +@section('title', 'Create your Katra account') +@section('heading', 'Create your Katra account') +@section('copy', 'Create an account for this instance and keep moving into the Katra workspace.') + +@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..295b300 --- /dev/null +++ b/resources/views/auth/reset-password.blade.php @@ -0,0 +1,59 @@ +@extends('auth.layout') + +@section('title', 'Choose a new Katra password') +@section('heading', 'Choose a new password') +@section('copy', 'Finish recovering your Katra account.') + +@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..78e3b22 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,4 +3,6 @@ use App\Http\Controllers\HomeController; use Illuminate\Support\Facades\Route; -Route::get('/', HomeController::class)->name('home'); +Route::view('/connect-server', 'auth.connect-server')->name('server.connect'); + +Route::middleware('auth')->get('/', HomeController::class)->name('home'); diff --git a/tests/Feature/DatabaseCacheOnSurrealTest.php b/tests/Feature/DatabaseCacheOnSurrealTest.php new file mode 100644 index 0000000..a343849 --- /dev/null +++ b/tests/Feature/DatabaseCacheOnSurrealTest.php @@ -0,0 +1,131 @@ +isAvailable()) { + $this->markTestSkipped('The `surreal` CLI is not available in this environment.'); + } + + $storagePath = storage_path('app/surrealdb/database-cache-test-'.Str::uuid()); + $originalDefaultConnection = config('database.default'); + $originalMigrationConnection = config('database.migrations.connection'); + $originalCacheStore = config('cache.default'); + $originalCacheDatabaseConnection = config('cache.stores.database.connection'); + $originalCacheLockConnection = config('cache.stores.database.lock_connection'); + + File::deleteDirectory($storagePath); + File::ensureDirectoryExists(dirname($storagePath)); + + try { + $server = retryStartingSurrealCacheServer($client, $storagePath); + + config()->set('database.default', 'surreal'); + config()->set('database.migrations.connection', null); + config()->set('surreal.host', '127.0.0.1'); + config()->set('surreal.port', $server['port']); + config()->set('surreal.endpoint', $server['endpoint']); + config()->set('surreal.username', 'root'); + config()->set('surreal.password', 'root'); + config()->set('surreal.namespace', 'katra'); + config()->set('surreal.database', 'database_cache_test'); + config()->set('surreal.storage_engine', 'surrealkv'); + config()->set('surreal.storage_path', $storagePath); + config()->set('surreal.runtime', 'local'); + config()->set('surreal.autostart', false); + config()->set('cache.default', 'database'); + config()->set('cache.stores.database.connection', 'surreal'); + config()->set('cache.stores.database.lock_connection', 'surreal'); + + app()->forgetInstance(SurrealConnection::class); + app()->forgetInstance(SurrealRuntimeManager::class); + DB::purge('surreal'); + Cache::forgetDriver('database'); + app()->forgetInstance('cache'); + app()->forgetInstance('cache.store'); + app()->forgetInstance('migration.repository'); + app()->forgetInstance('migrator'); + + expect(Artisan::call('migrate', [ + '--force' => true, + '--realpath' => true, + '--path' => database_path('migrations/0001_01_01_000001_create_cache_table.php'), + ]))->toBe(0); + + $store = Cache::store('database'); + + expect($store->add('login:127.0.0.1', 'first-hit', 60))->toBeTrue() + ->and($store->add('login:127.0.0.1', 'second-hit', 60))->toBeFalse() + ->and($store->get('login:127.0.0.1'))->toBe('first-hit'); + + expect($store->put('login:127.0.0.1', 'updated-hit', 60))->toBeTrue() + ->and($store->get('login:127.0.0.1'))->toBe('updated-hit'); + + expect($store->flush())->toBeTrue() + ->and($store->get('login:127.0.0.1'))->toBeNull(); + } finally { + config()->set('database.default', $originalDefaultConnection); + config()->set('database.migrations.connection', $originalMigrationConnection); + config()->set('cache.default', $originalCacheStore); + config()->set('cache.stores.database.connection', $originalCacheDatabaseConnection); + config()->set('cache.stores.database.lock_connection', $originalCacheLockConnection); + + app()->forgetInstance(SurrealConnection::class); + app()->forgetInstance(SurrealRuntimeManager::class); + DB::purge('surreal'); + Cache::forgetDriver('database'); + app()->forgetInstance('cache'); + app()->forgetInstance('cache.store'); + app()->forgetInstance('migration.repository'); + app()->forgetInstance('migrator'); + + if (isset($server['process'])) { + $server['process']->stop(1); + } + + File::deleteDirectory($storagePath); + } +}); + +/** + * @return array{endpoint: string, port: int, process: Process} + */ +function retryStartingSurrealCacheServer(SurrealCliClient $client, string $storagePath, int $attempts = 3): array +{ + $httpClient = app(SurrealHttpClient::class); + + for ($attempt = 1; $attempt <= $attempts; $attempt++) { + $port = random_int(10240, 65535); + $endpoint = sprintf('ws://127.0.0.1:%d', $port); + $process = $client->startLocalServer( + bindAddress: sprintf('127.0.0.1:%d', $port), + datastorePath: $storagePath, + username: 'root', + password: 'root', + storageEngine: 'surrealkv', + ); + + if ($httpClient->waitUntilReady($endpoint)) { + return [ + 'endpoint' => $endpoint, + 'port' => $port, + 'process' => $process, + ]; + } + + $process->stop(1); + } + + throw new RuntimeException('Unable to start the SurrealDB cache test runtime.'); +} diff --git a/tests/Feature/DesktopShellTest.php b/tests/Feature/DesktopShellTest.php index 8be23f8..bc99811 100644 --- a/tests/Feature/DesktopShellTest.php +++ b/tests/Feature/DesktopShellTest.php @@ -1,5 +1,18 @@ make([ + 'id' => 1, + 'first_name' => 'Derek', + 'last_name' => 'Bourgeois', + '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 +21,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 +112,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 +129,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/DesktopUiFeatureFlagTest.php b/tests/Feature/DesktopUiFeatureFlagTest.php index 375b0ae..f1ac718 100644 --- a/tests/Feature/DesktopUiFeatureFlagTest.php +++ b/tests/Feature/DesktopUiFeatureFlagTest.php @@ -6,6 +6,7 @@ use App\Features\Desktop\MvpShell; use App\Features\Desktop\TaskSurfaces; use App\Features\Desktop\WorkspaceNavigation; +use App\Models\User; use App\Support\Features\DesktopUi; use Laravel\Pennant\Attributes\Name; use Laravel\Pennant\Feature; @@ -69,6 +70,14 @@ Feature::for(DesktopUi::scope())->activate(WorkspaceNavigation::class); + $this->actingAs(User::factory()->make([ + 'id' => 1, + 'first_name' => 'Derek', + 'last_name' => 'Bourgeois', + 'name' => 'Derek Bourgeois', + 'email' => 'derek@katra.io', + ])); + expect(DesktopUi::active(WorkspaceNavigation::class))->toBeTrue() ->and(DesktopUi::states()['ui.desktop.workspace-navigation'])->toBeTrue(); @@ -83,6 +92,14 @@ Feature::for(DesktopUi::scope())->deactivate(MvpShell::class); + $this->actingAs(User::factory()->make([ + 'id' => 1, + 'first_name' => 'Derek', + 'last_name' => 'Bourgeois', + 'name' => 'Derek Bourgeois', + 'email' => 'derek@katra.io', + ])); + $this->get('/') ->assertSuccessful() ->assertSee('The MVP workspace shell is currently hidden.') diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index 826289c..5a160d0 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -1,7 +1,6 @@ get('/'); - - $response->assertSuccessful(); +test('guests are redirected to the login screen', function () { + $this->get('/') + ->assertRedirect(route('login')); }); diff --git a/tests/Feature/FortifyAuthenticationTest.php b/tests/Feature/FortifyAuthenticationTest.php new file mode 100644 index 0000000..b75cfdf --- /dev/null +++ b/tests/Feature/FortifyAuthenticationTest.php @@ -0,0 +1,140 @@ +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?') + ->assertSee('This instance') + ->assertSee('Server'); + + $this->get(route('register')) + ->assertSuccessful() + ->assertSee('Create your Katra account') + ->assertSee('First name') + ->assertSee('Last name'); + + $this->get(route('password.request')) + ->assertSuccessful() + ->assertSee('Reset your password'); + + $this->get(route('server.connect')) + ->assertSuccessful() + ->assertSee('Connect to a server'); +}); + +test('the login rate limiter uses the file cache store', function () { + config([ + 'cache.default' => 'database', + 'cache.limiter' => 'file', + ]); + + expect(cache()->driver(config('cache.limiter'))->getStore())->toBeInstanceOf(FileStore::class); +}); + +test('a user can register for a katra account', function () { + $this->post(route('register'), [ + 'first_name' => 'Derek', + 'last_name' => 'Bourgeois', + 'email' => 'derek@katra.io', + 'password' => 'password', + 'password_confirmation' => 'password', + ])->assertRedirect(route('home')); + + $this->assertAuthenticated(); + + $user = User::query()->where('email', 'derek@katra.io')->first(); + + expect($user)->not->toBeNull() + ->and($user?->first_name)->toBe('Derek') + ->and($user?->last_name)->toBe('Bourgeois') + ->and($user?->name)->toBe('Derek Bourgeois'); +}); + +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(); +}); diff --git a/tests/Unit/SurrealQueryBuilderTest.php b/tests/Unit/SurrealQueryBuilderTest.php index 18c8eff..e172eb6 100644 --- a/tests/Unit/SurrealQueryBuilderTest.php +++ b/tests/Unit/SurrealQueryBuilderTest.php @@ -5,6 +5,7 @@ use Carbon\CarbonImmutable; use Illuminate\Database\Query\Expression; use Illuminate\Database\Query\Grammars\Grammar; +use Illuminate\Database\Query\Processors\Processor; test('single-row inserts can contain array values without being treated as bulk inserts', function () { $builder = (new ReflectionClass(SurrealQueryBuilder::class))->newInstanceWithoutConstructor(); @@ -85,3 +86,21 @@ expect($encoded)->toBe("d'2026-03-24T13:15:00+00:00'"); }); + +test('upsert with no update columns behaves like insert or ignore', function () { + $connection = Mockery::mock(SurrealSchemaConnection::class); + $grammar = (new ReflectionClass(Grammar::class))->newInstanceWithoutConstructor(); + $processor = new Processor; + + $connection->shouldReceive('insertOrIgnoreRecord') + ->once() + ->with('features', ['name' => 'ui.desktop.mvp-shell']) + ->andReturnTrue(); + + $builder = new SurrealQueryBuilder($connection, $grammar, $processor); + $builder->from('features'); + + expect($builder->upsert([ + 'name' => 'ui.desktop.mvp-shell', + ], ['name'], []))->toBe(1); +});