Skip to content
Merged
6 changes: 3 additions & 3 deletions app/Concerns/PasswordValidationRules.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,18 @@

namespace App\Concerns;

use Illuminate\Validation\Rule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rules\Password;

trait PasswordValidationRules
{
/** @return array<int, Rule|array<mixed>|string> */
/** @return array<int, ValidationRule|array<mixed>|string> */
protected function passwordRules(): array
{
return ['required', 'string', Password::default(), 'confirmed'];
}

/** @return array<int, Rule|array<mixed>|string> */
/** @return array<int, ValidationRule|array<mixed>|string> */
protected function currentPasswordRules(): array
{
return ['required', 'string', 'current_password'];
Expand Down
7 changes: 4 additions & 3 deletions app/Concerns/ProfileValidationRules.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
namespace App\Concerns;

use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rule;

trait ProfileValidationRules
{
/** @return array<string, array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>> */
/** @return array<string, array<int, ValidationRule|array<mixed>|string>> */
protected function profileRules(?int $userId = null): array
{
return [
Expand All @@ -18,13 +19,13 @@ protected function profileRules(?int $userId = null): array
];
}

/** @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string> */
/** @return array<int, ValidationRule|array<mixed>|string> */
protected function nameRules(): array
{
return ['required', 'string', 'max:255'];
}

/** @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string> */
/** @return array<int, ValidationRule|array<mixed>|string> */
protected function emailRules(?int $userId = null): array
{
return [
Expand Down
84 changes: 84 additions & 0 deletions app/Console/Commands/InstallFeaturesCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Laravel\Chisel\Chisel;
use Laravel\Chisel\Question;
use Laravel\Chisel\Script;
use RuntimeException;

use function Laravel\Prompts\multiselect;
use function Laravel\Prompts\spin;

class InstallFeaturesCommand extends Command
Comment thread
techenby marked this conversation as resolved.
Outdated
{
protected $signature = 'install:features
{--answers= : JSON string of answers to skip interactive prompts}';

protected $description = 'Choose which starter kit features to keep';

public function handle(): int
{
if (getenv('LARAVEL_INSTALLER_DEFER_HOOKS') && $this->option('answers') === null) {
return self::SUCCESS;
}

if (! file_exists(base_path('chisel.php'))) {
return self::SUCCESS;
}

/** @var Script $script */
$script = require base_path('chisel.php');

$providedAnswers = $this->option('answers') === null
? []
: json_decode((string) $this->option('answers'), true, 512, JSON_THROW_ON_ERROR);

$answers = $script
->collectAnswers()
->onQuestion(fn (Question $question) => match ($question->type) {
'multiselect' => multiselect(
label: $question->label,
options: $question->options,
default: $question->default ?? [],
required: $question->required,
hint: $question->hint,
),
default => throw new RuntimeException("Unsupported question type [{$question->type}]."),
})
->interactive($this->input->isInteractive())
->withAnswers($providedAnswers);

$this->installNodeDependencies();

$script->chisel($answers);

$this->buildAssets();

return self::SUCCESS;
}

protected function installNodeDependencies(): void
{
$npm = Chisel::in(base_path())->npm();
$packageManager = $npm->packageManager();

spin(
fn () => $npm->install(),
"Installing dependencies with {$packageManager->value}...",
);
}

protected function buildAssets(): void
{
$npm = Chisel::in(base_path())->npm();

spin(
fn () => $npm->run('build'),
'Building assets...',
);
}
}
31 changes: 31 additions & 0 deletions app/Http/Responses/Concerns/RedirectsToCurrentTeam.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace App\Http\Responses\Concerns;

use App\Models\Team;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\URL;

trait RedirectsToCurrentTeam
{
protected function redirectPathForCurrentTeam(Request $request, string $redirect): string
{
$team = $this->currentTeam($request);

URL::defaults(['current_team' => $team->slug]);

return "/{$team->slug}{$redirect}";
}

protected function currentTeam(Request $request): Team
{
$user = $request->user();
$team = $user?->currentTeam ?? $user?->personalTeam();

abort_unless($team, 403);

return $team;
}
}
14 changes: 5 additions & 9 deletions app/Http/Responses/LoginResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,20 @@

namespace App\Http\Responses;

use App\Http\Responses\Concerns\RedirectsToCurrentTeam;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\URL;
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
use Laravel\Fortify\Fortify;
use Symfony\Component\HttpFoundation\Response;

class LoginResponse implements LoginResponseContract
{
use RedirectsToCurrentTeam;

public function toResponse($request): Response
{
$user = $request->user();
$team = $user?->currentTeam ?? $user?->personalTeam();

abort_unless($team, 403);

URL::defaults(['current_team' => $team->slug]);

return $request->wantsJson()
? new JsonResponse(['two_factor' => false], 200)
: redirect()->intended(route('dashboard'));
: redirect()->intended($this->redirectPathForCurrentTeam($request, Fortify::redirects('login')));
}
}
25 changes: 25 additions & 0 deletions app/Http/Responses/PasskeyLoginResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace App\Http\Responses;

use App\Http\Responses\Concerns\RedirectsToCurrentTeam;
use Illuminate\Http\JsonResponse;
use Laravel\Fortify\Fortify;
use Laravel\Passkeys\Contracts\PasskeyLoginResponse as PasskeyLoginResponseContract;
use Symfony\Component\HttpFoundation\Response;

class PasskeyLoginResponse implements PasskeyLoginResponseContract
{
use RedirectsToCurrentTeam;

public function toResponse($request): Response
{
$redirect = $this->redirectPathForCurrentTeam($request, Fortify::redirects('login'));

return $request->wantsJson()
? new JsonResponse(['redirect' => redirect()->intended($redirect)->getTargetUrl()], 200)
: redirect()->intended($redirect);
}
}
14 changes: 5 additions & 9 deletions app/Http/Responses/RegisterResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,20 @@

namespace App\Http\Responses;

use App\Http\Responses\Concerns\RedirectsToCurrentTeam;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\URL;
use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
use Laravel\Fortify\Fortify;
use Symfony\Component\HttpFoundation\Response;

class RegisterResponse implements RegisterResponseContract
{
use RedirectsToCurrentTeam;

public function toResponse($request): Response
{
$user = $request->user();
$team = $user?->currentTeam ?? $user?->personalTeam();

abort_unless($team, 403);

URL::defaults(['current_team' => $team->slug]);

return $request->wantsJson()
? new JsonResponse(['two_factor' => false], 201)
: redirect()->intended(route('dashboard'));
: redirect()->intended($this->redirectPathForCurrentTeam($request, Fortify::redirects('register')));
}
}
14 changes: 5 additions & 9 deletions app/Http/Responses/TwoFactorLoginResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,20 @@

namespace App\Http\Responses;

use App\Http\Responses\Concerns\RedirectsToCurrentTeam;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\URL;
use Laravel\Fortify\Contracts\TwoFactorLoginResponse as TwoFactorLoginResponseContract;
use Laravel\Fortify\Fortify;
use Symfony\Component\HttpFoundation\Response;

class TwoFactorLoginResponse implements TwoFactorLoginResponseContract
{
use RedirectsToCurrentTeam;

public function toResponse($request): Response
{
$user = $request->user();
$team = $user?->currentTeam ?? $user?->personalTeam();

abort_unless($team, 403);

URL::defaults(['current_team' => $team->slug]);

return $request->wantsJson()
? new JsonResponse(['two_factor' => false], 200)
: redirect()->intended(route('dashboard'));
: redirect()->intended($this->redirectPathForCurrentTeam($request, Fortify::redirects('login')));
}
}
14 changes: 5 additions & 9 deletions app/Http/Responses/VerifyEmailResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,20 @@

namespace App\Http\Responses;

use App\Http\Responses\Concerns\RedirectsToCurrentTeam;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\URL;
use Laravel\Fortify\Contracts\VerifyEmailResponse as VerifyEmailResponseContract;
use Laravel\Fortify\Fortify;
use Symfony\Component\HttpFoundation\Response;

class VerifyEmailResponse implements VerifyEmailResponseContract
{
use RedirectsToCurrentTeam;

public function toResponse($request): Response
{
$user = $request->user();
$team = $user?->currentTeam ?? $user?->personalTeam();

abort_unless($team, 403);

URL::defaults(['current_team' => $team->slug]);

return $request->wantsJson()
? new JsonResponse('', 204)
: redirect()->intended(route('dashboard') . '?verified=1');
: redirect()->intended($this->redirectPathForCurrentTeam($request, Fortify::redirects('email-verification')) . '?verified=1');
}
}
5 changes: 4 additions & 1 deletion app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,21 @@
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
use Laravel\Fortify\Contracts\PasskeyUser;
use Laravel\Fortify\PasskeyAuthenticatable;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;

#[Fillable(['name', 'email', 'password', 'current_team_id'])]
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
class User extends Authenticatable
class User extends Authenticatable implements PasskeyUser
{
use HasApiTokens;
/** @use HasFactory<UserFactory> */
use HasFactory;
use HasTeams;
use Notifiable;
use PasskeyAuthenticatable;
use TwoFactorAuthenticatable;

public function initials(): string
Expand Down
11 changes: 11 additions & 0 deletions app/Providers/FortifyServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use App\Http\Responses\LoginResponse;
use App\Http\Responses\PasskeyLoginResponse;
use App\Http\Responses\RegisterResponse;
use App\Http\Responses\TwoFactorLoginResponse;
use App\Http\Responses\VerifyEmailResponse;
Expand All @@ -20,12 +21,14 @@
use Laravel\Fortify\Contracts\TwoFactorLoginResponse as TwoFactorLoginResponseContract;
use Laravel\Fortify\Contracts\VerifyEmailResponse as VerifyEmailResponseContract;
use Laravel\Fortify\Fortify;
use Laravel\Passkeys\Contracts\PasskeyLoginResponse as PasskeyLoginResponseContract;

class FortifyServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(LoginResponseContract::class, LoginResponse::class);
$this->app->singleton(PasskeyLoginResponseContract::class, PasskeyLoginResponse::class);
$this->app->singleton(RegisterResponseContract::class, RegisterResponse::class);
$this->app->singleton(TwoFactorLoginResponseContract::class, TwoFactorLoginResponse::class);
$this->app->singleton(VerifyEmailResponseContract::class, VerifyEmailResponse::class);
Expand Down Expand Up @@ -66,5 +69,13 @@ private function configureRateLimiting(): void

return Limit::perMinute(5)->by($throttleKey);
});

RateLimiter::for('passkeys', function (Request $request) {
$credentialId = $request->input('credential.id');

return Limit::perMinute(10)->by(
($credentialId ?: $request->session()->getId()) . '|' . $request->ip(),
);
});
}
}
23 changes: 23 additions & 0 deletions chisel-paths.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

return [
'welcome' => 'resources/views/welcome.blade.php',
'login' => 'resources/views/pages/auth/login.blade.php',
'register' => 'resources/views/pages/auth/register.blade.php',
'confirm_password' => 'resources/views/pages/auth/confirm-password.blade.php',
'verify_email' => 'resources/views/pages/auth/verify-email.blade.php',
'two_factor_challenge' => 'resources/views/pages/auth/two-factor-challenge.blade.php',

'profile_files' => [
'resources/views/pages/settings/profile.blade.php',
],

'security_files' => [
'resources/views/pages/settings/security.blade.php',
],

'two_factor_files' => [
'resources/views/pages/settings/two-factor-setup-modal.blade.php',
'resources/views/pages/settings/two-factor/recovery-codes.blade.php',
],
];
Loading
Loading