diff --git a/.gitignore b/.gitignore index da316c5..32832f8 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,7 @@ yarn-error.log /.zed /storage/oauth/* /storage/testing/* -/storage/avatars/* \ No newline at end of file +/storage/avatars/* +**/caddy +frankenphp +frankenphp-worker.php diff --git a/Dockerfile b/Dockerfile index 7b1a1a7..b850a08 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,4 +5,7 @@ COPY ./hook.sh /app/hook.sh COPY . /app ENV ENABLE_QUEUE_WORKER=true -ENV ENABLE_SCHEDULER=true \ No newline at end of file +ENV ENABLE_SCHEDULER=true + +VOLUME [ "/app/storage/oauth" ] +VOLUME [ "/app/database" ] \ No newline at end of file diff --git a/composer.json b/composer.json index fa23fba..26d0725 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "require": { "php": "^8.2", "laravel/framework": "^12.0", + "laravel/octane": "^2.12", "laravel/tinker": "^2.10.1", "lcobucci/jwt": "^5.5", "livewire/flux": "^2.1.1", @@ -82,4 +83,4 @@ }, "minimum-stability": "stable", "prefer-stable": true -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index 4944869..1f12131 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "aac0908f1ea39fe3dc16e6b73b589749", + "content-hash": "5fbe8c27a97e598c161e41bbc086ecfc", "packages": [ { "name": "brick/math", @@ -1054,6 +1054,94 @@ ], "time": "2025-02-03T10:55:03+00:00" }, + { + "name": "laminas/laminas-diactoros", + "version": "3.6.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-diactoros.git", + "reference": "b068eac123f21c0e592de41deeb7403b88e0a89f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-diactoros/zipball/b068eac123f21c0e592de41deeb7403b88e0a89f", + "reference": "b068eac123f21c0e592de41deeb7403b88e0a89f", + "shasum": "" + }, + "require": { + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "conflict": { + "amphp/amp": "<2.6.4" + }, + "provide": { + "psr/http-factory-implementation": "^1.0", + "psr/http-message-implementation": "^1.1 || ^2.0" + }, + "require-dev": { + "ext-curl": "*", + "ext-dom": "*", + "ext-gd": "*", + "ext-libxml": "*", + "http-interop/http-factory-tests": "^2.2.0", + "laminas/laminas-coding-standard": "~3.0.0", + "php-http/psr7-integration-tests": "^1.4.0", + "phpunit/phpunit": "^10.5.36", + "psalm/plugin-phpunit": "^0.19.0", + "vimeo/psalm": "^5.26.1" + }, + "type": "library", + "extra": { + "laminas": { + "module": "Laminas\\Diactoros", + "config-provider": "Laminas\\Diactoros\\ConfigProvider" + } + }, + "autoload": { + "files": [ + "src/functions/create_uploaded_file.php", + "src/functions/marshal_headers_from_sapi.php", + "src/functions/marshal_method_from_sapi.php", + "src/functions/marshal_protocol_version_from_sapi.php", + "src/functions/normalize_server.php", + "src/functions/normalize_uploaded_files.php", + "src/functions/parse_cookie_header.php" + ], + "psr-4": { + "Laminas\\Diactoros\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "PSR HTTP Message implementations", + "homepage": "https://laminas.dev", + "keywords": [ + "http", + "laminas", + "psr", + "psr-17", + "psr-7" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-diactoros/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-diactoros/issues", + "rss": "https://github.com/laminas/laminas-diactoros/releases.atom", + "source": "https://github.com/laminas/laminas-diactoros" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2025-05-05T16:03:34+00:00" + }, { "name": "laravel/framework", "version": "v12.20.0", @@ -1269,6 +1357,96 @@ }, "time": "2025-07-08T15:02:21+00:00" }, + { + "name": "laravel/octane", + "version": "v2.12.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/octane.git", + "reference": "d606f3dffc785032f11c23a017334c99800f2e40" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/octane/zipball/d606f3dffc785032f11c23a017334c99800f2e40", + "reference": "d606f3dffc785032f11c23a017334c99800f2e40", + "shasum": "" + }, + "require": { + "laminas/laminas-diactoros": "^3.0", + "laravel/framework": "^10.10.1|^11.0|^12.0", + "laravel/prompts": "^0.1.24|^0.2.0|^0.3.0", + "laravel/serializable-closure": "^1.3|^2.0", + "nesbot/carbon": "^2.66.0|^3.0", + "php": "^8.1.0", + "symfony/console": "^6.0|^7.0", + "symfony/psr-http-message-bridge": "^2.2.0|^6.4|^7.0" + }, + "conflict": { + "spiral/roadrunner": "<2023.1.0", + "spiral/roadrunner-cli": "<2.6.0", + "spiral/roadrunner-http": "<3.3.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.6.1", + "inertiajs/inertia-laravel": "^1.3.2|^2.0", + "laravel/scout": "^10.2.1", + "laravel/socialite": "^5.6.1", + "livewire/livewire": "^2.12.3|^3.0", + "mockery/mockery": "^1.5.1", + "nunomaduro/collision": "^6.4.0|^7.5.2|^8.0", + "orchestra/testbench": "^8.21|^9.0|^10.0", + "phpstan/phpstan": "^2.1.7", + "phpunit/phpunit": "^10.4|^11.5", + "spiral/roadrunner-cli": "^2.6.0", + "spiral/roadrunner-http": "^3.3.0" + }, + "bin": [ + "bin/roadrunner-worker", + "bin/swoole-server" + ], + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Octane": "Laravel\\Octane\\Facades\\Octane" + }, + "providers": [ + "Laravel\\Octane\\OctaneServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Octane\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Supercharge your Laravel application's performance.", + "keywords": [ + "frankenphp", + "laravel", + "octane", + "roadrunner", + "swoole" + ], + "support": { + "issues": "https://github.com/laravel/octane/issues", + "source": "https://github.com/laravel/octane" + }, + "time": "2025-07-18T15:50:14+00:00" + }, { "name": "laravel/prompts", "version": "v0.3.6", @@ -1389,75 +1567,6 @@ }, "time": "2025-03-19T13:51:03+00:00" }, - { - "name": "laravel/telescope", - "version": "v5.10.2", - "source": { - "type": "git", - "url": "https://github.com/laravel/telescope.git", - "reference": "6d249d93ab06dc147ac62ea02b4272c2e7a24b72" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laravel/telescope/zipball/6d249d93ab06dc147ac62ea02b4272c2e7a24b72", - "reference": "6d249d93ab06dc147ac62ea02b4272c2e7a24b72", - "shasum": "" - }, - "require": { - "ext-json": "*", - "laravel/framework": "^8.37|^9.0|^10.0|^11.0|^12.0", - "php": "^8.0", - "symfony/console": "^5.3|^6.0|^7.0", - "symfony/var-dumper": "^5.0|^6.0|^7.0" - }, - "require-dev": { - "ext-gd": "*", - "guzzlehttp/guzzle": "^6.0|^7.0", - "laravel/octane": "^1.4|^2.0|dev-develop", - "orchestra/testbench": "^6.40|^7.37|^8.17|^9.0|^10.0", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^9.0|^10.5|^11.5" - }, - "type": "library", - "extra": { - "laravel": { - "providers": [ - "Laravel\\Telescope\\TelescopeServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "Laravel\\Telescope\\": "src/", - "Laravel\\Telescope\\Database\\Factories\\": "database/factories/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - }, - { - "name": "Mohamed Said", - "email": "mohamed@laravel.com" - } - ], - "description": "An elegant debug assistant for the Laravel framework.", - "keywords": [ - "debugging", - "laravel", - "monitoring" - ], - "support": { - "issues": "https://github.com/laravel/telescope/issues", - "source": "https://github.com/laravel/telescope/tree/v5.10.2" - }, - "time": "2025-07-24T05:26:13+00:00" - }, { "name": "laravel/tinker", "version": "v2.10.1", @@ -5276,6 +5385,89 @@ ], "time": "2025-04-17T09:11:12+00:00" }, + { + "name": "symfony/psr-http-message-bridge", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/psr-http-message-bridge.git", + "reference": "03f2f72319e7acaf2a9f6fcbe30ef17eec51594f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/03f2f72319e7acaf2a9f6fcbe30ef17eec51594f", + "reference": "03f2f72319e7acaf2a9f6fcbe30ef17eec51594f", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/http-message": "^1.0|^2.0", + "symfony/http-foundation": "^6.4|^7.0" + }, + "conflict": { + "php-http/discovery": "<1.15", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "nyholm/psr7": "^1.1", + "php-http/discovery": "^1.15", + "psr/log": "^1.1.4|^2|^3", + "symfony/browser-kit": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/framework-bundle": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\PsrHttpMessage\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "PSR HTTP message bridge", + "homepage": "https://symfony.com", + "keywords": [ + "http", + "http-message", + "psr-17", + "psr-7" + ], + "support": { + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-26T08:57:56+00:00" + }, { "name": "symfony/routing", "version": "v7.3.0", @@ -6790,6 +6982,75 @@ }, "time": "2025-05-19T13:19:21+00:00" }, + { + "name": "laravel/telescope", + "version": "v5.10.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/telescope.git", + "reference": "6d249d93ab06dc147ac62ea02b4272c2e7a24b72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/telescope/zipball/6d249d93ab06dc147ac62ea02b4272c2e7a24b72", + "reference": "6d249d93ab06dc147ac62ea02b4272c2e7a24b72", + "shasum": "" + }, + "require": { + "ext-json": "*", + "laravel/framework": "^8.37|^9.0|^10.0|^11.0|^12.0", + "php": "^8.0", + "symfony/console": "^5.3|^6.0|^7.0", + "symfony/var-dumper": "^5.0|^6.0|^7.0" + }, + "require-dev": { + "ext-gd": "*", + "guzzlehttp/guzzle": "^6.0|^7.0", + "laravel/octane": "^1.4|^2.0|dev-develop", + "orchestra/testbench": "^6.40|^7.37|^8.17|^9.0|^10.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.0|^10.5|^11.5" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Telescope\\TelescopeServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Telescope\\": "src/", + "Laravel\\Telescope\\Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Mohamed Said", + "email": "mohamed@laravel.com" + } + ], + "description": "An elegant debug assistant for the Laravel framework.", + "keywords": [ + "debugging", + "laravel", + "monitoring" + ], + "support": { + "issues": "https://github.com/laravel/telescope/issues", + "source": "https://github.com/laravel/telescope/tree/v5.10.2" + }, + "time": "2025-07-24T05:26:13+00:00" + }, { "name": "mockery/mockery", "version": "1.6.12", diff --git a/config/octane.php b/config/octane.php new file mode 100644 index 0000000..8cfba01 --- /dev/null +++ b/config/octane.php @@ -0,0 +1,224 @@ + env('OCTANE_SERVER', 'roadrunner'), + + /* + |-------------------------------------------------------------------------- + | Force HTTPS + |-------------------------------------------------------------------------- + | + | When this configuration value is set to "true", Octane will inform the + | framework that all absolute links must be generated using the HTTPS + | protocol. Otherwise your links may be generated using plain HTTP. + | + */ + + 'https' => env('OCTANE_HTTPS', false), + + /* + |-------------------------------------------------------------------------- + | Octane Listeners + |-------------------------------------------------------------------------- + | + | All of the event listeners for Octane's events are defined below. These + | listeners are responsible for resetting your application's state for + | the next request. You may even add your own listeners to the list. + | + */ + + 'listeners' => [ + WorkerStarting::class => [ + EnsureUploadedFilesAreValid::class, + EnsureUploadedFilesCanBeMoved::class, + ], + + RequestReceived::class => [ + ...Octane::prepareApplicationForNextOperation(), + ...Octane::prepareApplicationForNextRequest(), + // + ], + + RequestHandled::class => [ + // + ], + + RequestTerminated::class => [ + // FlushUploadedFiles::class, + ], + + TaskReceived::class => [ + ...Octane::prepareApplicationForNextOperation(), + // + ], + + TaskTerminated::class => [ + // + ], + + TickReceived::class => [ + ...Octane::prepareApplicationForNextOperation(), + // + ], + + TickTerminated::class => [ + // + ], + + OperationTerminated::class => [ + FlushOnce::class, + FlushTemporaryContainerInstances::class, + // DisconnectFromDatabases::class, + // CollectGarbage::class, + ], + + WorkerErrorOccurred::class => [ + ReportException::class, + StopWorkerIfNecessary::class, + ], + + WorkerStopping::class => [ + CloseMonologHandlers::class, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Warm / Flush Bindings + |-------------------------------------------------------------------------- + | + | The bindings listed below will either be pre-warmed when a worker boots + | or they will be flushed before every new request. Flushing a binding + | will force the container to resolve that binding again when asked. + | + */ + + 'warm' => [ + ...Octane::defaultServicesToWarm(), + ], + + 'flush' => [ + // + ], + + /* + |-------------------------------------------------------------------------- + | Octane Swoole Tables + |-------------------------------------------------------------------------- + | + | While using Swoole, you may define additional tables as required by the + | application. These tables can be used to store data that needs to be + | quickly accessed by other workers on the particular Swoole server. + | + */ + + 'tables' => [ + 'example:1000' => [ + 'name' => 'string:1000', + 'votes' => 'int', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Octane Swoole Cache Table + |-------------------------------------------------------------------------- + | + | While using Swoole, you may leverage the Octane cache, which is powered + | by a Swoole table. You may set the maximum number of rows as well as + | the number of bytes per row using the configuration options below. + | + */ + + 'cache' => [ + 'rows' => 1000, + 'bytes' => 10000, + ], + + /* + |-------------------------------------------------------------------------- + | File Watching + |-------------------------------------------------------------------------- + | + | The following list of files and directories will be watched when using + | the --watch option offered by Octane. If any of the directories and + | files are changed, Octane will automatically reload your workers. + | + */ + + 'watch' => [ + 'app', + 'bootstrap', + 'config/**/*.php', + 'database/**/*.php', + 'public/**/*.php', + 'resources/**/*.php', + 'routes', + 'composer.lock', + '.env', + ], + + /* + |-------------------------------------------------------------------------- + | Garbage Collection Threshold + |-------------------------------------------------------------------------- + | + | When executing long-lived PHP scripts such as Octane, memory can build + | up before being cleared by PHP. You can force Octane to run garbage + | collection if your application consumes this amount of megabytes. + | + */ + + 'garbage' => 50, + + /* + |-------------------------------------------------------------------------- + | Maximum Execution Time + |-------------------------------------------------------------------------- + | + | The following setting configures the maximum execution time for requests + | being handled by Octane. You may set this value to 0 to indicate that + | there isn't a specific time limit on Octane request execution time. + | + */ + + 'max_execution_time' => 30, + +]; diff --git a/database/factories/ApplicationFactory.php b/database/factories/ApplicationFactory.php index 864e696..6a87056 100644 --- a/database/factories/ApplicationFactory.php +++ b/database/factories/ApplicationFactory.php @@ -111,4 +111,82 @@ class ApplicationFactory extends Factory 'icon' => "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/{$kebabName}.webp", ]; } + + /** + * Indicate that the application has no icon. + */ + public function withoutIcon(): static + { + return $this->state(fn (array $attributes) => [ + 'icon' => null, + ]); + } + + /** + * Indicate that the application uses a custom icon URL. + */ + public function withCustomIcon(string $iconUrl = null): static + { + return $this->state(fn (array $attributes) => [ + 'icon' => $iconUrl ?? fake()->imageUrl(100, 100, 'apps', true), + ]); + } + + /** + * Create an application with localhost redirect URI for testing. + */ + public function localhost(int $port = 3000): static + { + return $this->state(fn (array $attributes) => [ + 'redirect_uri' => "http://localhost:{$port}/auth/callback", + ]); + } + + /** + * Create an application with HTTPS redirect URI. + */ + public function secure(): static + { + return $this->state(fn (array $attributes) => [ + 'redirect_uri' => 'https://' . fake()->domainName() . '/auth/callback', + ]); + } + + /** + * Create a Grafana application for testing. + */ + public function grafana(): static + { + return $this->state(fn (array $attributes) => [ + 'name' => 'Grafana', + 'redirect_uri' => 'https://grafana.' . fake()->domainName() . '/login/generic_oauth', + 'icon' => 'https://cdn.jsdelivr.net/gh/selfhst/icons/webp/grafana.webp', + ]); + } + + /** + * Create a Nextcloud application for testing. + */ + public function nextcloud(): static + { + return $this->state(fn (array $attributes) => [ + 'name' => 'Nextcloud', + 'redirect_uri' => 'https://cloud.' . fake()->domainName() . '/apps/user_oidc/code', + 'icon' => 'https://cdn.jsdelivr.net/gh/selfhst/icons/webp/nextcloud.webp', + ]); + } + + /** + * Create a test application with predictable values. + */ + public function testApp(): static + { + return $this->state(fn (array $attributes) => [ + 'name' => 'Test Application', + 'client_id' => 'test-client-id', + 'client_secret' => 'test-client-secret', + 'redirect_uri' => 'http://localhost:3000/auth/callback', + 'icon' => null, + ]); + } } diff --git a/database/factories/AuthenticationTokenFactory.php b/database/factories/AuthenticationTokenFactory.php index 3829091..0ac6f5b 100644 --- a/database/factories/AuthenticationTokenFactory.php +++ b/database/factories/AuthenticationTokenFactory.php @@ -30,4 +30,133 @@ class AuthenticationTokenFactory extends Factory 'user_agent' => fake()->userAgent(), ]; } + + /** + * Indicate that the token is expired. + */ + public function expired(): static + { + return $this->state(fn (array $attributes) => [ + 'expires_at' => now()->subDay(), + ]); + } + + /** + * Indicate that the token expires soon. + */ + public function expiringSoon(): static + { + return $this->state(fn (array $attributes) => [ + 'expires_at' => now()->addHours(fake()->numberBetween(1, 24)), + ]); + } + + /** + * Indicate that the token was issued recently. + */ + public function recent(): static + { + return $this->state(fn (array $attributes) => [ + 'issued_at' => now()->subMinutes(fake()->numberBetween(1, 60)), + ]); + } + + /** + * Indicate that the token was issued long ago. + */ + public function old(): static + { + return $this->state(fn (array $attributes) => [ + 'issued_at' => now()->subDays(fake()->numberBetween(7, 30)), + ]); + } + + /** + * Create a token for a specific user. + */ + public function forUser(User $user): static + { + return $this->state(fn (array $attributes) => [ + 'user_id' => $user->id, + ]); + } + + /** + * Create a token for a specific application. + */ + public function forApplication(Application $application): static + { + return $this->state(fn (array $attributes) => [ + 'application_id' => $application->id, + ]); + } + + /** + * Create a token with a specific token value for testing. + */ + public function withToken(string $token): static + { + return $this->state(fn (array $attributes) => [ + 'token' => $token, + ]); + } + + /** + * Create a token with a mobile user agent. + */ + public function mobile(): static + { + $mobileAgents = [ + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1', + 'Mozilla/5.0 (Linux; Android 11; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36', + 'Mozilla/5.0 (iPad; CPU OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1', + ]; + + return $this->state(fn (array $attributes) => [ + 'user_agent' => fake()->randomElement($mobileAgents), + ]); + } + + /** + * Create a token with a desktop user agent. + */ + public function desktop(): static + { + $desktopAgents = [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + ]; + + return $this->state(fn (array $attributes) => [ + 'user_agent' => fake()->randomElement($desktopAgents), + ]); + } + + /** + * Create a token from a local IP address. + */ + public function localNetwork(): static + { + $localIPs = [ + fake()->localIpv4(), + '192.168.1.' . fake()->numberBetween(2, 254), + '10.0.0.' . fake()->numberBetween(2, 254), + '172.16.0.' . fake()->numberBetween(2, 254), + ]; + + return $this->state(fn (array $attributes) => [ + 'ip' => fake()->randomElement($localIPs), + ]); + } + + /** + * Create a token that expires in a specific timeframe. + */ + public function expiresIn(string $timeframe): static + { + return $this->state(fn (array $attributes) => [ + 'expires_at' => now()->add($timeframe), + ]); + } } \ No newline at end of file diff --git a/database/factories/InvitationFactory.php b/database/factories/InvitationFactory.php index b5fb9b2..f4a6322 100644 --- a/database/factories/InvitationFactory.php +++ b/database/factories/InvitationFactory.php @@ -24,6 +24,8 @@ class InvitationFactory extends Factory 'invited_by' => User::factory(), 'expires_at' => now()->addDays(7), 'email_sent' => fake()->boolean(30), + 'accepted_at' => null, + 'user_id' => null, ]; } @@ -37,17 +39,38 @@ class InvitationFactory extends Factory ]); } + /** + * Indicate that the invitation expires soon. + */ + public function expiringSoon(): static + { + return $this->state(fn(array $attributes) => [ + 'expires_at' => now()->addHours(fake()->numberBetween(1, 24)), + ]); + } + /** * Indicate that the invitation has been accepted. */ public function accepted(): static { return $this->state(fn(array $attributes) => [ - 'accepted_at' => now(), + 'accepted_at' => now()->subDays(fake()->numberBetween(0, 5)), 'user_id' => User::factory(), ]); } + /** + * Indicate that the invitation was accepted by a specific user. + */ + public function acceptedBy(User $user): static + { + return $this->state(fn(array $attributes) => [ + 'accepted_at' => now()->subDays(fake()->numberBetween(0, 5)), + 'user_id' => $user->id, + ]); + } + /** * Indicate that the email was sent. */ @@ -57,4 +80,87 @@ class InvitationFactory extends Factory 'email_sent' => true, ]); } + + /** + * Indicate that the email was not sent. + */ + public function emailNotSent(): static + { + return $this->state(fn(array $attributes) => [ + 'email_sent' => false, + ]); + } + + /** + * Create an invitation from a specific user. + */ + public function from(User $inviter): static + { + return $this->state(fn(array $attributes) => [ + 'invited_by' => $inviter->id, + ]); + } + + /** + * Create an invitation for a specific email. + */ + public function forEmail(string $email): static + { + return $this->state(fn(array $attributes) => [ + 'email' => $email, + ]); + } + + /** + * Create an invitation with a specific code for testing. + */ + public function withCode(string $code): static + { + return $this->state(fn(array $attributes) => [ + 'code' => $code, + ]); + } + + /** + * Create a pending invitation (default state but explicit). + */ + public function pending(): static + { + return $this->state(fn(array $attributes) => [ + 'accepted_at' => null, + 'user_id' => null, + ]); + } + + /** + * Create an invitation that expires in a specific timeframe. + */ + public function expiresIn(string $timeframe): static + { + return $this->state(fn(array $attributes) => [ + 'expires_at' => now()->add($timeframe), + ]); + } + + /** + * Create a recent invitation. + */ + public function recent(): static + { + return $this->state(fn(array $attributes) => [ + 'created_at' => now()->subMinutes(fake()->numberBetween(1, 60)), + 'updated_at' => now()->subMinutes(fake()->numberBetween(1, 60)), + ]); + } + + /** + * Create an old invitation. + */ + public function old(): static + { + return $this->state(fn(array $attributes) => [ + 'created_at' => now()->subDays(fake()->numberBetween(7, 30)), + 'updated_at' => now()->subDays(fake()->numberBetween(7, 30)), + ]); + } } diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 0c99fba..ec0e21b 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -30,6 +30,9 @@ class UserFactory extends Factory 'password' => static::$password ??= Hash::make('password'), 'remember_token' => Str::random(10), 'is_admin' => false, + 'auto_approve_apps' => fake()->boolean(30), // 30% chance of auto-approval + 'preferred_username' => null, + 'avatar' => null, ]; } @@ -52,4 +55,88 @@ class UserFactory extends Factory 'is_admin' => true, ]); } + + /** + * Indicate that the user has auto-approval enabled. + */ + public function autoApprove(): static + { + return $this->state(fn (array $attributes) => [ + 'auto_approve_apps' => true, + ]); + } + + /** + * Indicate that the user has auto-approval disabled. + */ + public function noAutoApprove(): static + { + return $this->state(fn (array $attributes) => [ + 'auto_approve_apps' => false, + ]); + } + + /** + * Indicate that the user has a preferred username. + */ + public function withPreferredUsername(string $username = null): static + { + return $this->state(fn (array $attributes) => [ + 'preferred_username' => $username ?? fake()->userName(), + ]); + } + + /** + * Indicate that the user has an avatar. + */ + public function withAvatar(string $avatar = null): static + { + return $this->state(fn (array $attributes) => [ + 'avatar' => $avatar ?? 'avatars/' . fake()->uuid() . '.jpg', + ]); + } + + /** + * Create a complete user profile with all optional fields. + */ + public function complete(): static + { + return $this->state(fn (array $attributes) => [ + 'preferred_username' => fake()->userName(), + 'avatar' => 'avatars/' . fake()->uuid() . '.jpg', + 'auto_approve_apps' => true, + ]); + } + + /** + * Create a user with a specific password for testing. + */ + public function withPassword(string $password): static + { + return $this->state(fn (array $attributes) => [ + 'password' => Hash::make($password), + ]); + } + + /** + * Create a user that was recently created (useful for testing verification flows). + */ + public function recent(): static + { + return $this->state(fn (array $attributes) => [ + 'created_at' => now()->subMinutes(5), + 'updated_at' => now()->subMinutes(5), + ]); + } + + /** + * Create a user that's been around for a while. + */ + public function established(): static + { + return $this->state(fn (array $attributes) => [ + 'created_at' => now()->subDays(fake()->numberBetween(30, 365)), + 'updated_at' => now()->subDays(fake()->numberBetween(1, 30)), + ]); + } } diff --git a/resources/css/app.css b/resources/css/app.css index ad6eeed..70a2dd7 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -52,7 +52,7 @@ } [data-flux-label] { - @apply !mb-0 !leading-tight; + @apply !mb-0 !leading-tight; } input:focus[data-flux-control], @@ -64,3 +64,32 @@ select:focus[data-flux-control] { /* \[:where(&)\]:size-4 { @apply size-4; } */ + +/* Re-assign Flux's gray of choice... */ +@theme { + --color-zinc-50: var(--color-neutral-50); + --color-zinc-100: var(--color-neutral-100); + --color-zinc-200: var(--color-neutral-200); + --color-zinc-300: var(--color-neutral-300); + --color-zinc-400: var(--color-neutral-400); + --color-zinc-500: var(--color-neutral-500); + --color-zinc-600: var(--color-neutral-600); + --color-zinc-700: var(--color-neutral-700); + --color-zinc-800: var(--color-neutral-800); + --color-zinc-900: var(--color-neutral-900); + --color-zinc-950: var(--color-neutral-950); +} + +@theme { + --color-accent: var(--color-rose-500); + --color-accent-content: var(--color-rose-500); + --color-accent-foreground: var(--color-white); +} + +@layer theme { + .dark { + --color-accent: var(--color-rose-500); + --color-accent-content: var(--color-rose-400); + --color-accent-foreground: var(--color-white); + } +} \ No newline at end of file diff --git a/resources/views/components/app-logo.blade.php b/resources/views/components/app-logo.blade.php index 66aa2d9..85171bb 100644 --- a/resources/views/components/app-logo.blade.php +++ b/resources/views/components/app-logo.blade.php @@ -1,5 +1,4 @@ -