feat(portal): Fortify auth + Login/2FA/Dashboard + component kit
Backend (Fortify): - laravel/fortify with TOTP two-factor + recovery codes; User uses TwoFactorAuthenticatable; 2FA credentials hidden from serialization. - Views off — pages are full-page class-based Livewire components (R1/R2); Fortify handles POST actions. Home redirect -> /dashboard. v1 scope: login + 2FA only (no public register/reset/passkeys). Seeder gated to local/testing. Component kit (Blade, token-based, a11y): - button, input, checkbox, alert, card, badge, stat-tile, otp-input (Alpine, auto-advance/paste, -safe submit), progress-stepper, nav-item, icon (Lucide), plus layouts/portal-app app-shell (sidebar drawer + topbar + menu). Screens (localized DE/EN, R16): - Login (form -> login.store), Two-factor challenge (OTP + recovery fallback), Dashboard (KPI stat tiles, instance card, provisioning stepper fixtures, activity). Routes English (R13). Tests + verification: - Pest: 14 green (login ok/invalid/throttle, dashboard guard, component render). - R12 browser (Puppeteer, prod assets): /, /login, /two-factor-challenge and the authenticated /dashboard all HTTP 200 with ZERO console errors; login flow verified end-to-end. - Test isolation fixed (force test env over injected .env). - Reviewed with Codex (R15): 4 rounds, all findings fixed, final pass clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/portal-design
parent
53c2a12d6d
commit
254a7d46a0
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
|
||||
class CreateNewUser implements CreatesNewUsers
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and create a newly registered user.
|
||||
*
|
||||
* @param array<string, string> $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']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use Illuminate\Contracts\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
trait PasswordValidationRules
|
||||
{
|
||||
/**
|
||||
* Get the validation rules used to validate passwords.
|
||||
*
|
||||
* @return array<int, Rule|array<mixed>|string>
|
||||
*/
|
||||
protected function passwordRules(): array
|
||||
{
|
||||
return ['required', 'string', Password::default(), 'confirmed'];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
||||
|
||||
class ResetUserPassword implements ResetsUserPasswords
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and reset the user's forgotten password.
|
||||
*
|
||||
* @param array<string, string> $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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
|
||||
|
||||
class UpdateUserPassword implements UpdatesUserPasswords
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and update the user's password.
|
||||
*
|
||||
* @param array<string, string> $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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
||||
|
||||
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
{
|
||||
/**
|
||||
* Validate and update the given user's profile information.
|
||||
*
|
||||
* @param array<string, string> $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<string, string> $input
|
||||
*/
|
||||
protected function updateVerifiedUser(User $user, array $input): void
|
||||
{
|
||||
$user->forceFill([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'email_verified_at' => null,
|
||||
])->save();
|
||||
|
||||
$user->sendEmailVerificationNotification();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Auth;
|
||||
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.portal')]
|
||||
class Login extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.auth.login');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Auth;
|
||||
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.portal')]
|
||||
class TwoFactorChallenge extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.auth.two-factor-challenge');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.portal-app')]
|
||||
class Dashboard extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
// Fixture data until the provisioning engine (separate handoff) provides
|
||||
// real instances / provisioning_runs. The dashboard is a pure view (§12.3).
|
||||
$storageUsed = 172;
|
||||
$storageQuota = 250;
|
||||
$storagePercent = (int) round($storageUsed / max(1, $storageQuota) * 100);
|
||||
$storageTone = $storagePercent >= 90 ? 'danger' : ($storagePercent >= 85 ? 'warning' : 'accent');
|
||||
|
||||
return view('livewire.dashboard', [
|
||||
'storageUsed' => $storageUsed,
|
||||
'storageQuota' => $storageQuota,
|
||||
'storagePercent' => $storagePercent,
|
||||
'storageTone' => $storageTone,
|
||||
'activeUsers' => 14,
|
||||
'lastBackup' => __('dashboard.today_time', ['time' => '04:12']),
|
||||
'uptime' => '99.98',
|
||||
'instanceUrl' => 'https://kunde-demo.clupilot.com',
|
||||
'instanceStatus' => 'active',
|
||||
'provisioning' => false,
|
||||
'steps' => [
|
||||
['label' => __('dashboard.steps.validate'), 'state' => 'done'],
|
||||
['label' => __('dashboard.steps.provision'), 'state' => 'done'],
|
||||
['label' => __('dashboard.steps.configure'), 'state' => 'done'],
|
||||
['label' => __('dashboard.steps.acceptance'), 'state' => 'done'],
|
||||
],
|
||||
'activity' => [
|
||||
['label' => __('dashboard.activity_items.backup_ok'), 'time' => '04:12'],
|
||||
['label' => __('dashboard.activity_items.user_added'), 'time' => __('dashboard.yesterday')],
|
||||
['label' => __('dashboard.activity_items.login'), 'time' => __('dashboard.yesterday')],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,13 +9,14 @@ use Illuminate\Database\Eloquent\Attributes\Hidden;
|
|||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
#[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
use HasFactory, Notifiable, TwoFactorAuthenticatable;
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Actions\Fortify\CreateNewUser;
|
||||
use App\Actions\Fortify\ResetUserPassword;
|
||||
use App\Actions\Fortify\UpdateUserPassword;
|
||||
use App\Actions\Fortify\UpdateUserProfileInformation;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Fortify\Actions\RedirectIfTwoFactorAuthenticatable;
|
||||
use Laravel\Fortify\Fortify;
|
||||
|
||||
class FortifyServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
Fortify::createUsersUsing(CreateNewUser::class);
|
||||
Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
|
||||
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
|
||||
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
|
||||
Fortify::redirectUserForTwoFactorAuthenticationUsing(RedirectIfTwoFactorAuthenticatable::class);
|
||||
|
||||
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'));
|
||||
});
|
||||
|
||||
RateLimiter::for('passkeys', function (Request $request) {
|
||||
$credentialId = $request->input('credential.id');
|
||||
|
||||
return Limit::perMinute(10)->by(
|
||||
($credentialId ?: $request->session()->getId()).'|'.$request->ip()
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\FortifyServiceProvider::class,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"laravel/fortify": "^1.37",
|
||||
"laravel/framework": "^13.8",
|
||||
"laravel/reverb": "^1.11",
|
||||
"laravel/tinker": "^3.0",
|
||||
|
|
@ -21,6 +22,8 @@
|
|||
"laravel/pint": "^1.27",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"pestphp/pest": "^4.7",
|
||||
"pestphp/pest-plugin-livewire": "^4.1",
|
||||
"phpunit/phpunit": "^12.5.12"
|
||||
},
|
||||
"autoload": {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,183 @@
|
|||
<?php
|
||||
|
||||
use Laravel\Fortify\Features;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fortify Guard
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which authentication guard Fortify will use while
|
||||
| authenticating users. This value should correspond with one of your
|
||||
| guards that is already present in your "auth" configuration file.
|
||||
|
|
||||
*/
|
||||
|
||||
'guard' => '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' => '/dashboard',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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',
|
||||
'passkeys' => 'passkeys',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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.
|
||||
|
|
||||
*/
|
||||
|
||||
// Pages are full-page class-based Livewire components mapped in routes/web.php
|
||||
// (R1/R2); Fortify registers only its action (POST) routes, not view routes.
|
||||
'views' => false,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Passkeys
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These settings configure Fortify's passkey (WebAuthn) support. Passkeys
|
||||
| allow users to sign in without needing to remember credentials since
|
||||
| they use public-key cryptography - making them immune to breaches.
|
||||
|
|
||||
*/
|
||||
|
||||
'passkeys' => [
|
||||
'relying_party_id' => parse_url(config('app.url'), PHP_URL_HOST),
|
||||
'allowed_origins' => [config('app.url')],
|
||||
'timeout' => 60000,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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' => [
|
||||
// v1 customer portal: login + TOTP two-factor only. Public registration,
|
||||
// password reset, profile/password management and passkeys are out of
|
||||
// v1 scope (design handoff §2) — enable later as separate screens.
|
||||
// Features::registration(),
|
||||
// Features::resetPasswords(),
|
||||
// Features::emailVerification(),
|
||||
// Features::updateProfileInformation(),
|
||||
// Features::updatePasswords(),
|
||||
Features::twoFactorAuthentication([
|
||||
'confirm' => true,
|
||||
'confirmPassword' => true,
|
||||
// 'window' => 0,
|
||||
]),
|
||||
// Features::passkeys([...]),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Laravel\Passkeys\Passkeys;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('passkeys', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignIdFor(Passkeys::userModel(), 'user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->string('credential_id')->unique();
|
||||
$table->json('credential');
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('user_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('passkeys');
|
||||
}
|
||||
};
|
||||
|
|
@ -3,23 +3,27 @@
|
|||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
use WithoutModelEvents;
|
||||
|
||||
/**
|
||||
* Seed the application's database.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// User::factory(10)->create();
|
||||
// Never create a known-credential account outside local/testing.
|
||||
if (! app()->environment('local', 'testing')) {
|
||||
return;
|
||||
}
|
||||
|
||||
User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
User::updateOrCreate(
|
||||
['email' => env('SEED_ADMIN_EMAIL', 'admin@clupilot.local')],
|
||||
[
|
||||
'name' => 'Admin',
|
||||
'password' => Hash::make(env('SEED_ADMIN_PASSWORD', 'password')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
// Laravel default auth lines
|
||||
'failed' => 'Diese Zugangsdaten stimmen nicht mit unseren Daten überein.',
|
||||
'password' => 'Das angegebene Passwort ist nicht korrekt.',
|
||||
'throttle' => 'Zu viele Anmeldeversuche. Bitte in :seconds Sekunden erneut versuchen.',
|
||||
|
||||
// Login
|
||||
'login_title' => 'Bei CluPilot anmelden',
|
||||
'login_subtitle' => 'Melden Sie sich mit Ihren Zugangsdaten an.',
|
||||
'email' => 'E-Mail',
|
||||
'password_label' => 'Passwort',
|
||||
'remember' => 'Angemeldet bleiben',
|
||||
'sign_in' => 'Anmelden',
|
||||
|
||||
// Two-factor
|
||||
'twofa_title' => 'Zwei-Faktor-Bestätigung',
|
||||
'twofa_subtitle' => 'Geben Sie den Code aus Ihrer Authenticator-App ein.',
|
||||
'twofa_recovery_subtitle' => 'Geben Sie einen Ihrer Wiederherstellungscodes ein.',
|
||||
'otp_digit' => 'Ziffer',
|
||||
'recovery_code' => 'Wiederherstellungscode',
|
||||
'verify' => 'Bestätigen',
|
||||
'use_recovery' => 'Wiederherstellungscode verwenden',
|
||||
'use_otp' => 'Authenticator-Code verwenden',
|
||||
];
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Übersicht',
|
||||
'subtitle' => 'Status Ihrer Cloud auf einen Blick.',
|
||||
|
||||
'nav' => [
|
||||
'overview' => 'Übersicht',
|
||||
'cloud' => 'Meine Cloud',
|
||||
'users' => 'Nutzer',
|
||||
'backups' => 'Backups',
|
||||
'invoices' => 'Rechnungen',
|
||||
'support' => 'Support',
|
||||
],
|
||||
|
||||
'open_nav' => 'Navigation öffnen',
|
||||
'close_nav' => 'Navigation schließen',
|
||||
'logout' => 'Abmelden',
|
||||
|
||||
'kpi' => [
|
||||
'storage' => 'Speicher',
|
||||
'users' => 'Aktive Nutzer',
|
||||
'backup' => 'Letztes Backup',
|
||||
'uptime' => 'Verfügbarkeit',
|
||||
],
|
||||
|
||||
'instance' => 'Instanz',
|
||||
'open_cloud' => 'Cloud öffnen',
|
||||
'provisioning' => 'Bereitstellung',
|
||||
'activity' => 'Aktivität',
|
||||
'today_time' => 'Heute :time',
|
||||
'yesterday' => 'Gestern',
|
||||
|
||||
'status' => [
|
||||
'active' => 'Aktiv',
|
||||
'provisioning' => 'Bereitstellung',
|
||||
'suspended' => 'Ausgesetzt',
|
||||
'failed' => 'Fehlgeschlagen',
|
||||
],
|
||||
|
||||
'steps' => [
|
||||
'validate' => 'Bestellung geprüft',
|
||||
'provision' => 'Instanz erstellt',
|
||||
'configure' => 'Cloud konfiguriert',
|
||||
'acceptance' => 'Abnahme bestanden',
|
||||
],
|
||||
|
||||
'step_state' => [
|
||||
'pending' => 'Ausstehend',
|
||||
'running' => 'Läuft',
|
||||
'done' => 'Abgeschlossen',
|
||||
'failed' => 'Fehlgeschlagen',
|
||||
],
|
||||
|
||||
'activity_items' => [
|
||||
'backup_ok' => 'Backup erfolgreich abgeschlossen',
|
||||
'user_added' => 'Neuer Nutzer hinzugefügt',
|
||||
'login' => 'Anmeldung am Portal',
|
||||
],
|
||||
];
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
// Laravel default auth lines
|
||||
'failed' => 'These credentials do not match our records.',
|
||||
'password' => 'The provided password is incorrect.',
|
||||
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
|
||||
|
||||
// Login
|
||||
'login_title' => 'Sign in to CluPilot',
|
||||
'login_subtitle' => 'Sign in with your credentials.',
|
||||
'email' => 'Email',
|
||||
'password_label' => 'Password',
|
||||
'remember' => 'Stay signed in',
|
||||
'sign_in' => 'Sign in',
|
||||
|
||||
// Two-factor
|
||||
'twofa_title' => 'Two-factor confirmation',
|
||||
'twofa_subtitle' => 'Enter the code from your authenticator app.',
|
||||
'twofa_recovery_subtitle' => 'Enter one of your recovery codes.',
|
||||
'otp_digit' => 'Digit',
|
||||
'recovery_code' => 'Recovery code',
|
||||
'verify' => 'Verify',
|
||||
'use_recovery' => 'Use a recovery code',
|
||||
'use_otp' => 'Use authenticator code',
|
||||
];
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Overview',
|
||||
'subtitle' => 'Your cloud status at a glance.',
|
||||
|
||||
'nav' => [
|
||||
'overview' => 'Overview',
|
||||
'cloud' => 'My cloud',
|
||||
'users' => 'Users',
|
||||
'backups' => 'Backups',
|
||||
'invoices' => 'Invoices',
|
||||
'support' => 'Support',
|
||||
],
|
||||
|
||||
'open_nav' => 'Open navigation',
|
||||
'close_nav' => 'Close navigation',
|
||||
'logout' => 'Sign out',
|
||||
|
||||
'kpi' => [
|
||||
'storage' => 'Storage',
|
||||
'users' => 'Active users',
|
||||
'backup' => 'Last backup',
|
||||
'uptime' => 'Uptime',
|
||||
],
|
||||
|
||||
'instance' => 'Instance',
|
||||
'open_cloud' => 'Open cloud',
|
||||
'provisioning' => 'Provisioning',
|
||||
'activity' => 'Activity',
|
||||
'today_time' => 'Today :time',
|
||||
'yesterday' => 'Yesterday',
|
||||
|
||||
'status' => [
|
||||
'active' => 'Active',
|
||||
'provisioning' => 'Provisioning',
|
||||
'suspended' => 'Suspended',
|
||||
'failed' => 'Failed',
|
||||
],
|
||||
|
||||
'steps' => [
|
||||
'validate' => 'Order validated',
|
||||
'provision' => 'Instance created',
|
||||
'configure' => 'Cloud configured',
|
||||
'acceptance' => 'Acceptance passed',
|
||||
],
|
||||
|
||||
'step_state' => [
|
||||
'pending' => 'Pending',
|
||||
'running' => 'Running',
|
||||
'done' => 'Done',
|
||||
'failed' => 'Failed',
|
||||
],
|
||||
|
||||
'activity_items' => [
|
||||
'backup_ok' => 'Backup completed successfully',
|
||||
'user_added' => 'New user added',
|
||||
'login' => 'Portal sign-in',
|
||||
],
|
||||
];
|
||||
22
phpunit.xml
22
phpunit.xml
|
|
@ -18,17 +18,19 @@
|
|||
</include>
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<!-- force=true so these override the dev .env that docker-compose injects
|
||||
as real container env vars (otherwise tests use redis/mariadb). -->
|
||||
<env name="APP_ENV" value="testing" force="true"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_CONNECTION" value="sqlite"/>
|
||||
<env name="DB_DATABASE" value=":memory:"/>
|
||||
<env name="DB_URL" value=""/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4" force="true"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null" force="true"/>
|
||||
<env name="CACHE_STORE" value="array" force="true"/>
|
||||
<env name="DB_CONNECTION" value="sqlite" force="true"/>
|
||||
<env name="DB_DATABASE" value=":memory:" force="true"/>
|
||||
<env name="DB_URL" value="" force="true"/>
|
||||
<env name="MAIL_MAILER" value="array" force="true"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
|
||||
<env name="SESSION_DRIVER" value="array" force="true"/>
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||
|
|
|
|||
|
|
@ -40,6 +40,11 @@
|
|||
.font-mono {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Alpine: hide elements until initialised. */
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,49 @@
|
|||
//
|
||||
|
||||
/**
|
||||
* Echo exposes an expressive API for subscribing to channels and listening
|
||||
* for events that are broadcast by Laravel. Echo and event broadcasting
|
||||
* allow your team to quickly build robust real-time web applications.
|
||||
*/
|
||||
|
||||
import './echo';
|
||||
|
||||
// Segmented OTP input behaviour (used by <x-ui.otp-input>). Alpine ships with
|
||||
// Livewire; register on alpine:init.
|
||||
document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('otpInput', ({ length = 6 } = {}) => ({
|
||||
length,
|
||||
digits: Array(length).fill(''),
|
||||
|
||||
get value() {
|
||||
return this.digits.join('');
|
||||
},
|
||||
|
||||
cells() {
|
||||
return this.$root.querySelectorAll('input[inputmode="numeric"]');
|
||||
},
|
||||
|
||||
focusCell(i) {
|
||||
const cell = this.cells()[i];
|
||||
if (cell) cell.focus();
|
||||
},
|
||||
|
||||
onInput(i, e) {
|
||||
const digit = e.target.value.replace(/\D/g, '').slice(-1);
|
||||
this.digits[i] = digit;
|
||||
if (digit && i < this.length - 1) this.focusCell(i + 1);
|
||||
this.maybeSubmit();
|
||||
},
|
||||
|
||||
onBackspace(i) {
|
||||
if (!this.digits[i] && i > 0) this.focusCell(i - 1);
|
||||
},
|
||||
|
||||
onPaste(e) {
|
||||
const text = (e.clipboardData.getData('text') || '').replace(/\D/g, '').slice(0, this.length);
|
||||
for (let i = 0; i < this.length; i++) this.digits[i] = text[i] || '';
|
||||
this.focusCell(Math.min(text.length, this.length - 1));
|
||||
this.maybeSubmit();
|
||||
},
|
||||
|
||||
maybeSubmit() {
|
||||
if (this.value.length === this.length) {
|
||||
// Wait for Alpine to flush :value into the hidden input, otherwise
|
||||
// requestSubmit() can post a stale/short code.
|
||||
this.$nextTick(() => this.$root.closest('form')?.requestSubmit());
|
||||
}
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
@props(['variant' => 'info'])
|
||||
@php
|
||||
$variants = [
|
||||
'info' => 'bg-info-bg border-info-border text-info',
|
||||
'success' => 'bg-success-bg border-success-border text-success',
|
||||
'warning' => 'bg-warning-bg border-warning-border text-warning',
|
||||
'danger' => 'bg-danger-bg border-danger-border text-danger',
|
||||
];
|
||||
@endphp
|
||||
<div role="alert" {{ $attributes->merge(['class' => 'rounded border px-3 py-2 text-sm '.($variants[$variant] ?? $variants['info'])]) }}>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
@props([
|
||||
// status → semantic tone. Always carries a text label (never colour-only, R9/§9).
|
||||
'status' => 'info',
|
||||
])
|
||||
@php
|
||||
$tones = [
|
||||
'active' => 'success',
|
||||
'online' => 'success',
|
||||
'provisioning' => 'info',
|
||||
'pending' => 'info',
|
||||
'suspended' => 'warning',
|
||||
'warning' => 'warning',
|
||||
'failed' => 'danger',
|
||||
'offline' => 'danger',
|
||||
'info' => 'info',
|
||||
];
|
||||
$tone = $tones[$status] ?? 'info';
|
||||
$classes = [
|
||||
'success' => 'bg-success-bg border-success-border text-success',
|
||||
'warning' => 'bg-warning-bg border-warning-border text-warning',
|
||||
'danger' => 'bg-danger-bg border-danger-border text-danger',
|
||||
'info' => 'bg-info-bg border-info-border text-info',
|
||||
][$tone];
|
||||
@endphp
|
||||
<span {{ $attributes->merge(['class' => 'inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium '.$classes]) }}>
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
||||
{{ $slot }}
|
||||
</span>
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
@props([
|
||||
'variant' => 'primary',
|
||||
'size' => 'md',
|
||||
'type' => 'button',
|
||||
'loading' => false,
|
||||
])
|
||||
@php
|
||||
$base = 'inline-flex items-center justify-center gap-2 rounded font-semibold transition select-none disabled:opacity-50 disabled:pointer-events-none';
|
||||
|
||||
$sizes = [
|
||||
'sm' => 'px-3 py-1.5 text-sm',
|
||||
'md' => 'px-4 py-2.5 text-sm',
|
||||
];
|
||||
|
||||
// Accent buttons use the AA-safe darker fill (#c2560a) so white text passes
|
||||
// WCAG AA; #f97316 alone is only ~2.8:1 (design handoff §6.1).
|
||||
$variants = [
|
||||
'primary' => 'bg-accent-active text-on-accent hover:bg-accent-press',
|
||||
'secondary' => 'border border-line-strong bg-surface text-body hover:bg-surface-hover',
|
||||
'ghost' => 'text-body hover:bg-surface-hover',
|
||||
'danger' => 'bg-danger text-on-accent hover:opacity-90',
|
||||
];
|
||||
|
||||
$classes = $base.' '.($sizes[$size] ?? $sizes['md']).' '.($variants[$variant] ?? $variants['primary']);
|
||||
@endphp
|
||||
<button
|
||||
type="{{ $type }}"
|
||||
{{ $attributes->merge(['class' => $classes]) }}
|
||||
@disabled($loading)
|
||||
@if ($loading) aria-busy="true" @endif
|
||||
>
|
||||
@if ($loading)
|
||||
<svg class="size-4 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.4 0 0 5.4 0 12h4z"></path>
|
||||
</svg>
|
||||
@endif
|
||||
{{ $slot }}
|
||||
</button>
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
@props([
|
||||
'title' => null,
|
||||
'padding' => 'p-6',
|
||||
])
|
||||
<div {{ $attributes->merge(['class' => 'rounded-lg border border-line bg-surface shadow-xs']) }}>
|
||||
@if ($title)
|
||||
<div class="border-b border-line px-6 py-3">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wide text-faint">{{ $title }}</h2>
|
||||
</div>
|
||||
@endif
|
||||
<div class="{{ $padding }}">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
@props([
|
||||
'name',
|
||||
'label' => null,
|
||||
])
|
||||
@php $id = $attributes->get('id', $name); @endphp
|
||||
<label for="{{ $id }}" class="inline-flex items-center gap-2 text-sm text-body select-none">
|
||||
<input
|
||||
id="{{ $id }}"
|
||||
name="{{ $name }}"
|
||||
type="checkbox"
|
||||
{{ $attributes->merge(['class' => 'size-4 rounded border-line-strong text-accent-active']) }}
|
||||
>
|
||||
@if ($label)<span>{{ $label }}</span>@endif
|
||||
{{ $slot }}
|
||||
</label>
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
@props(['name'])
|
||||
@php
|
||||
// Lucide (https://lucide.dev) path bodies — trusted, static markup.
|
||||
$icons = [
|
||||
'gauge' => '<path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>',
|
||||
'cloud' => '<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/>',
|
||||
'users' => '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
'database' => '<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/>',
|
||||
'receipt' => '<path d="M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1Z"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 17.5v-11"/>',
|
||||
'life-buoy' => '<circle cx="12" cy="12" r="10"/><path d="m4.93 4.93 4.24 4.24"/><path d="m14.83 9.17 4.24-4.24"/><path d="m14.83 14.83 4.24 4.24"/><path d="m9.17 14.83-4.24 4.24"/><circle cx="12" cy="12" r="4"/>',
|
||||
'menu' => '<path d="M4 12h16"/><path d="M4 6h16"/><path d="M4 18h16"/>',
|
||||
'log-out' => '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>',
|
||||
'chevron-down' => '<path d="m6 9 6 6 6-6"/>',
|
||||
'external-link'=> '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
|
||||
'shield-check' => '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/>',
|
||||
];
|
||||
$body = $icons[$name] ?? '';
|
||||
@endphp
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
{{ $attributes->merge(['class' => 'size-5']) }}
|
||||
>{!! $body !!}</svg>
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
@props([
|
||||
'name',
|
||||
'label' => null,
|
||||
'type' => 'text',
|
||||
'hint' => null,
|
||||
])
|
||||
@php
|
||||
// $errors is shared on web requests; default it so the component also renders
|
||||
// standalone (e.g. in isolated Blade::render tests).
|
||||
$errors ??= new \Illuminate\Support\ViewErrorBag;
|
||||
$id = $attributes->get('id', $name);
|
||||
$hasError = $errors->has($name);
|
||||
$field = 'block w-full rounded border bg-surface px-3 py-2 text-sm text-ink placeholder:text-faint transition '
|
||||
.($hasError ? 'border-danger bg-danger-bg' : 'border-line');
|
||||
@endphp
|
||||
<div class="space-y-1.5">
|
||||
@if ($label)
|
||||
<label for="{{ $id }}" class="block text-sm font-medium text-body">{{ $label }}</label>
|
||||
@endif
|
||||
|
||||
<input
|
||||
id="{{ $id }}"
|
||||
name="{{ $name }}"
|
||||
type="{{ $type }}"
|
||||
@if ($hasError) aria-invalid="true" aria-describedby="{{ $id }}-error" @endif
|
||||
{{ $attributes->merge(['class' => $field]) }}
|
||||
>
|
||||
|
||||
@if ($hint && ! $hasError)
|
||||
<p class="text-xs text-faint">{{ $hint }}</p>
|
||||
@endif
|
||||
|
||||
@error($name)
|
||||
<p id="{{ $id }}-error" class="text-xs text-danger">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
@props([
|
||||
'href' => '#',
|
||||
'active' => false,
|
||||
'disabled' => false,
|
||||
])
|
||||
@php
|
||||
$base = 'flex items-center gap-3 rounded border-l-2 px-3 py-2 text-sm font-medium min-h-11';
|
||||
$state = $active
|
||||
? 'bg-accent-subtle text-accent-text border-accent'
|
||||
: 'text-muted border-transparent hover:bg-surface-hover hover:text-body';
|
||||
@endphp
|
||||
@if ($disabled)
|
||||
{{-- Section not built yet: render a non-interactive item, never a dead link. --}}
|
||||
<span aria-disabled="true" {{ $attributes->merge(['class' => $base.' border-transparent text-faint cursor-not-allowed']) }}>
|
||||
@isset($icon)<span class="shrink-0">{{ $icon }}</span>@endisset
|
||||
<span>{{ $slot }}</span>
|
||||
</span>
|
||||
@else
|
||||
<a href="{{ $href }}" @if ($active) aria-current="page" @endif {{ $attributes->merge(['class' => $base.' '.$state]) }}>
|
||||
@isset($icon)<span class="shrink-0">{{ $icon }}</span>@endisset
|
||||
<span>{{ $slot }}</span>
|
||||
</a>
|
||||
@endif
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
@props([
|
||||
'name' => 'code',
|
||||
'length' => 6,
|
||||
'label' => null,
|
||||
])
|
||||
{{-- Segmented one-time-code input. Alpine keeps the visible segments; a hidden
|
||||
field carries the joined value for the native form POST. --}}
|
||||
<div
|
||||
x-data="otpInput({ length: {{ (int) $length }} })"
|
||||
class="flex gap-2"
|
||||
role="group"
|
||||
@if ($label) aria-label="{{ $label }}" @endif
|
||||
>
|
||||
<input type="hidden" name="{{ $name }}" :value="value">
|
||||
<template x-for="(digit, i) in digits" :key="i">
|
||||
<input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
maxlength="1"
|
||||
class="size-12 rounded border border-line bg-surface text-center font-mono text-lg text-ink"
|
||||
:aria-label="`{{ $label ?? __('auth.otp_digit') }} ${i + 1}`"
|
||||
x-model="digits[i]"
|
||||
x-ref="cell"
|
||||
@input="onInput(i, $event)"
|
||||
@keydown.backspace="onBackspace(i, $event)"
|
||||
@paste.prevent="onPaste($event)"
|
||||
>
|
||||
</template>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
@props([
|
||||
// array of ['label' => string, 'state' => pending|running|done|failed]
|
||||
'steps' => [],
|
||||
])
|
||||
<ol class="space-y-0">
|
||||
@foreach ($steps as $i => $step)
|
||||
@php
|
||||
$state = $step['state'] ?? 'pending';
|
||||
$isLast = $loop->last;
|
||||
$dot = [
|
||||
'done' => 'bg-success border-success',
|
||||
'running' => 'bg-info border-info animate-pulse',
|
||||
'failed' => 'bg-danger border-danger',
|
||||
'pending' => 'bg-surface border-line-strong',
|
||||
][$state] ?? 'bg-surface border-line-strong';
|
||||
$labelTone = $state === 'pending' ? 'text-muted' : 'text-ink';
|
||||
@endphp
|
||||
<li class="flex gap-3">
|
||||
<div class="flex flex-col items-center">
|
||||
<span class="mt-1 size-3 shrink-0 rounded-pill border-2 {{ $dot }}" aria-hidden="true"></span>
|
||||
@unless ($isLast)
|
||||
<span class="w-px flex-1 bg-line" aria-hidden="true"></span>
|
||||
@endunless
|
||||
</div>
|
||||
<div class="pb-4">
|
||||
<p class="text-sm font-medium {{ $labelTone }}">{{ $step['label'] }}</p>
|
||||
<p class="text-xs text-faint">{{ __('dashboard.step_state.'.$state) }}</p>
|
||||
</div>
|
||||
</li>
|
||||
@endforeach
|
||||
</ol>
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
@props([
|
||||
'label',
|
||||
'value',
|
||||
'unit' => null,
|
||||
'percent' => null, // 0..100 → renders a bar
|
||||
'tone' => 'accent', // accent | success | warning | danger
|
||||
])
|
||||
@php
|
||||
$bar = [
|
||||
'accent' => 'bg-accent',
|
||||
'success' => 'bg-success',
|
||||
'warning' => 'bg-warning',
|
||||
'danger' => 'bg-danger',
|
||||
][$tone] ?? 'bg-accent';
|
||||
$pct = is_null($percent) ? null : max(0, min(100, (int) $percent));
|
||||
@endphp
|
||||
<div {{ $attributes->merge(['class' => 'rounded-lg border border-line bg-surface p-4 shadow-xs']) }}>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ $label }}</p>
|
||||
<p class="mt-1 flex items-baseline gap-1">
|
||||
<span class="font-mono text-2xl font-semibold text-ink">{{ $value }}</span>
|
||||
@if ($unit)<span class="text-sm text-muted">{{ $unit }}</span>@endif
|
||||
</p>
|
||||
@if (! is_null($pct))
|
||||
<div class="mt-3 h-1.5 w-full overflow-hidden rounded-pill bg-surface-2">
|
||||
{{-- the one allowed inline style: dynamic progress width (R4) --}}
|
||||
<div class="h-full rounded-pill {{ $bar }}" style="width: {{ $pct }}%"></div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{{ $title ?? config('app.name') }}</title>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="min-h-full bg-bg text-body antialiased" x-data="{ nav: false }">
|
||||
<div class="flex min-h-screen">
|
||||
{{-- Sidebar: fixed drawer on small screens, static on large (R7). --}}
|
||||
<aside
|
||||
class="fixed inset-y-0 left-0 z-40 w-60 border-r border-line bg-surface px-3 py-4 transition-transform lg:static lg:translate-x-0"
|
||||
:class="nav ? 'translate-x-0' : '-translate-x-full'"
|
||||
>
|
||||
<div class="flex items-center justify-between px-3 pb-4">
|
||||
<span class="font-mono text-lg font-semibold tracking-tight text-ink">CluPilot</span>
|
||||
<button type="button" class="lg:hidden text-muted" @click="nav = false" aria-label="{{ __('dashboard.close_nav') }}">
|
||||
<x-ui.icon name="chevron-down" class="size-5 rotate-90" />
|
||||
</button>
|
||||
</div>
|
||||
<nav class="space-y-1">
|
||||
<x-ui.nav-item :href="route('dashboard')" :active="request()->routeIs('dashboard')">
|
||||
<x-slot:icon><x-ui.icon name="gauge" /></x-slot:icon>
|
||||
{{ __('dashboard.nav.overview') }}
|
||||
</x-ui.nav-item>
|
||||
<x-ui.nav-item :disabled="true">
|
||||
<x-slot:icon><x-ui.icon name="cloud" /></x-slot:icon>
|
||||
{{ __('dashboard.nav.cloud') }}
|
||||
</x-ui.nav-item>
|
||||
<x-ui.nav-item :disabled="true">
|
||||
<x-slot:icon><x-ui.icon name="users" /></x-slot:icon>
|
||||
{{ __('dashboard.nav.users') }}
|
||||
</x-ui.nav-item>
|
||||
<x-ui.nav-item :disabled="true">
|
||||
<x-slot:icon><x-ui.icon name="database" /></x-slot:icon>
|
||||
{{ __('dashboard.nav.backups') }}
|
||||
</x-ui.nav-item>
|
||||
<x-ui.nav-item :disabled="true">
|
||||
<x-slot:icon><x-ui.icon name="receipt" /></x-slot:icon>
|
||||
{{ __('dashboard.nav.invoices') }}
|
||||
</x-ui.nav-item>
|
||||
<x-ui.nav-item :disabled="true">
|
||||
<x-slot:icon><x-ui.icon name="life-buoy" /></x-slot:icon>
|
||||
{{ __('dashboard.nav.support') }}
|
||||
</x-ui.nav-item>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{{-- Mobile backdrop --}}
|
||||
<div x-show="nav" x-cloak @click="nav = false" class="fixed inset-0 z-30 bg-ink/20 lg:hidden"></div>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<header class="flex h-14 items-center justify-between border-b border-line bg-surface px-4">
|
||||
<button type="button" class="lg:hidden inline-flex min-h-11 min-w-11 items-center justify-center rounded text-muted hover:bg-surface-hover" @click="nav = true" aria-label="{{ __('dashboard.open_nav') }}">
|
||||
<x-ui.icon name="menu" />
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
|
||||
@auth
|
||||
<div class="relative" x-data="{ open: false }" @keydown.escape="open = false">
|
||||
<button type="button" class="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface-hover" @click="open = !open" :aria-expanded="open" aria-haspopup="menu">
|
||||
<span class="flex size-8 items-center justify-center rounded-pill bg-accent-subtle text-sm font-semibold text-accent-text">{{ \Illuminate\Support\Str::upper(\Illuminate\Support\Str::substr(auth()->user()->name, 0, 1)) }}</span>
|
||||
<span class="hidden text-sm text-body sm:block">{{ auth()->user()->name }}</span>
|
||||
<x-ui.icon name="chevron-down" class="size-4 text-muted" />
|
||||
</button>
|
||||
<div x-show="open" x-cloak @click.outside="open = false" class="absolute right-0 mt-1 w-48 rounded-lg border border-line bg-surface py-1 shadow-md">
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-body hover:bg-surface-hover">
|
||||
<x-ui.icon name="log-out" class="size-4" />
|
||||
{{ __('dashboard.logout') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endauth
|
||||
</header>
|
||||
|
||||
<main class="mx-auto w-full max-w-[1200px] flex-1 p-6 lg:p-8">
|
||||
{{ $slot }}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<div class="flex min-h-screen items-center justify-center p-6">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="mb-6 text-center">
|
||||
<span class="font-mono text-xl font-semibold tracking-tight text-ink">CluPilot</span>
|
||||
</div>
|
||||
|
||||
<x-ui.card>
|
||||
<h1 class="text-lg font-semibold text-ink">{{ __('auth.login_title') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('auth.login_subtitle') }}</p>
|
||||
|
||||
@if (session('status'))
|
||||
<x-ui.alert variant="info" class="mt-4">{{ session('status') }}</x-ui.alert>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('login.store') }}" class="mt-5 space-y-4">
|
||||
@csrf
|
||||
<x-ui.input
|
||||
name="email"
|
||||
type="email"
|
||||
:label="__('auth.email')"
|
||||
autocomplete="email"
|
||||
autofocus
|
||||
required
|
||||
value="{{ old('email') }}"
|
||||
/>
|
||||
<x-ui.input
|
||||
name="password"
|
||||
type="password"
|
||||
:label="__('auth.password_label')"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
<x-ui.checkbox name="remember" :label="__('auth.remember')" value="1" />
|
||||
|
||||
<x-ui.button type="submit" variant="primary" class="w-full">
|
||||
{{ __('auth.sign_in') }}
|
||||
</x-ui.button>
|
||||
</form>
|
||||
</x-ui.card>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<div class="flex min-h-screen items-center justify-center p-6" x-data="{ recovery: false }">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="mb-6 text-center">
|
||||
<span class="font-mono text-xl font-semibold tracking-tight text-ink">CluPilot</span>
|
||||
</div>
|
||||
|
||||
<x-ui.card>
|
||||
<h1 class="text-lg font-semibold text-ink">{{ __('auth.twofa_title') }}</h1>
|
||||
<p x-show="! recovery" class="mt-1 text-sm text-muted">{{ __('auth.twofa_subtitle') }}</p>
|
||||
<p x-show="recovery" x-cloak class="mt-1 text-sm text-muted">{{ __('auth.twofa_recovery_subtitle') }}</p>
|
||||
|
||||
@error('code')
|
||||
<x-ui.alert variant="danger" class="mt-4">{{ $message }}</x-ui.alert>
|
||||
@enderror
|
||||
@error('recovery_code')
|
||||
<x-ui.alert variant="danger" class="mt-4">{{ $message }}</x-ui.alert>
|
||||
@enderror
|
||||
|
||||
{{-- TOTP code --}}
|
||||
<form x-show="! recovery" method="POST" action="{{ route('two-factor.login.store') }}" class="mt-5 space-y-4">
|
||||
@csrf
|
||||
<x-ui.otp-input name="code" :label="__('auth.otp_digit')" />
|
||||
<x-ui.button type="submit" variant="primary" class="w-full">{{ __('auth.verify') }}</x-ui.button>
|
||||
</form>
|
||||
|
||||
{{-- Recovery code fallback --}}
|
||||
<form x-show="recovery" x-cloak method="POST" action="{{ route('two-factor.login.store') }}" class="mt-5 space-y-4">
|
||||
@csrf
|
||||
<x-ui.input name="recovery_code" :label="__('auth.recovery_code')" autocomplete="one-time-code" />
|
||||
<x-ui.button type="submit" variant="primary" class="w-full">{{ __('auth.verify') }}</x-ui.button>
|
||||
</form>
|
||||
|
||||
<button type="button" class="mt-4 text-sm font-medium text-accent-text hover:underline" @click="recovery = ! recovery">
|
||||
<span x-show="! recovery">{{ __('auth.use_recovery') }}</span>
|
||||
<span x-show="recovery" x-cloak>{{ __('auth.use_otp') }}</span>
|
||||
</button>
|
||||
</x-ui.card>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('dashboard.title') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('dashboard.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
{{-- KPI row: 1 → 2 → 4 (R7) --}}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<x-ui.stat-tile
|
||||
:label="__('dashboard.kpi.storage')"
|
||||
:value="$storageUsed"
|
||||
:unit="'/ '.$storageQuota.' GB'"
|
||||
:percent="$storagePercent"
|
||||
:tone="$storageTone"
|
||||
/>
|
||||
<x-ui.stat-tile :label="__('dashboard.kpi.users')" :value="$activeUsers" />
|
||||
<x-ui.stat-tile :label="__('dashboard.kpi.backup')" :value="$lastBackup" />
|
||||
<x-ui.stat-tile :label="__('dashboard.kpi.uptime')" :value="$uptime" unit="%" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{{-- Instance card --}}
|
||||
<x-ui.card class="lg:col-span-2">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.instance') }}</p>
|
||||
<p class="mt-1 font-mono text-sm text-ink">{{ $instanceUrl }}</p>
|
||||
<div class="mt-2">
|
||||
<x-ui.badge :status="$instanceStatus">{{ __('dashboard.status.'.$instanceStatus) }}</x-ui.badge>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="{{ $instanceUrl }}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center justify-center gap-2 rounded bg-accent-active px-4 py-2.5 text-sm font-semibold text-on-accent transition hover:bg-accent-press"
|
||||
>
|
||||
<x-ui.icon name="external-link" class="size-4" />
|
||||
{{ __('dashboard.open_cloud') }}
|
||||
</a>
|
||||
</div>
|
||||
</x-ui.card>
|
||||
|
||||
{{-- Provisioning progress (live via Reverb once the engine lands) --}}
|
||||
<x-ui.card :title="__('dashboard.provisioning')">
|
||||
<x-ui.progress-stepper :steps="$steps" />
|
||||
</x-ui.card>
|
||||
</div>
|
||||
|
||||
{{-- Activity --}}
|
||||
<x-ui.card :title="__('dashboard.activity')">
|
||||
<ul class="divide-y divide-line">
|
||||
@foreach ($activity as $item)
|
||||
<li class="flex items-center justify-between py-2.5 text-sm">
|
||||
<span class="text-body">{{ $item['label'] }}</span>
|
||||
<span class="font-mono text-xs text-faint">{{ $item['time'] }}</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</x-ui.card>
|
||||
</div>
|
||||
|
|
@ -1,7 +1,19 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Auth\Login;
|
||||
use App\Livewire\Auth\TwoFactorChallenge;
|
||||
use App\Livewire\Dashboard;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', function () {
|
||||
return view('welcome');
|
||||
Route::get('/', fn () => view('welcome'))->name('home');
|
||||
|
||||
// Guest auth pages — full-page class-based Livewire components (R1/R2). Fortify
|
||||
// handles the POST actions (login.store, two-factor.login.store) with views off.
|
||||
Route::middleware('guest')->group(function () {
|
||||
Route::get('/login', Login::class)->name('login');
|
||||
Route::get('/two-factor-challenge', TwoFactorChallenge::class)->name('two-factor.login');
|
||||
});
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::get('/dashboard', Dashboard::class)->name('dashboard');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
// Post to Fortify with a matching CSRF token in session + body (the browser sends
|
||||
// a real token; CSRF itself is not what these tests exercise).
|
||||
function loginPost(array $data)
|
||||
{
|
||||
return test()
|
||||
->withSession(['_token' => 'test-token'])
|
||||
->post('/login', array_merge(['_token' => 'test-token'], $data));
|
||||
}
|
||||
|
||||
it('renders the login page', function () {
|
||||
$this->get('/login')
|
||||
->assertOk()
|
||||
->assertSee('CluPilot')
|
||||
->assertSee('name="email"', false);
|
||||
});
|
||||
|
||||
it('logs in with valid credentials and redirects to the dashboard', function () {
|
||||
$user = User::factory()->create(['password' => Hash::make('secret-password')]);
|
||||
|
||||
loginPost(['email' => $user->email, 'password' => 'secret-password'])
|
||||
->assertRedirect('/dashboard');
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
});
|
||||
|
||||
it('rejects invalid credentials', function () {
|
||||
$user = User::factory()->create(['password' => Hash::make('secret-password')]);
|
||||
|
||||
loginPost(['email' => $user->email, 'password' => 'wrong-password'])
|
||||
->assertSessionHasErrors('email');
|
||||
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
||||
it('throttles repeated failed attempts', function () {
|
||||
$user = User::factory()->create(['password' => Hash::make('secret-password')]);
|
||||
|
||||
// Exceed the 5/min login limiter.
|
||||
foreach (range(1, 6) as $ignored) {
|
||||
loginPost(['email' => $user->email, 'password' => 'wrong-password']);
|
||||
}
|
||||
|
||||
// Once throttled, even correct credentials are refused.
|
||||
loginPost(['email' => $user->email, 'password' => 'secret-password']);
|
||||
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
it('renders the two-factor challenge page for guests', function () {
|
||||
$this->get('/two-factor-challenge')
|
||||
->assertOk()
|
||||
->assertSee('CluPilot')
|
||||
->assertSee('name="code"', false);
|
||||
});
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
|
||||
it('renders a primary button with the AA-safe accent fill', function () {
|
||||
$html = Blade::render('<x-ui.button>Go</x-ui.button>');
|
||||
|
||||
expect($html)->toContain('bg-accent-active')->toContain('Go');
|
||||
});
|
||||
|
||||
it('renders an input with a linked label and error region', function () {
|
||||
$html = Blade::render('<x-ui.input name="email" label="E-Mail" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('for="email"')
|
||||
->toContain('name="email"')
|
||||
->toContain('E-Mail');
|
||||
});
|
||||
|
||||
it('maps badge status to a semantic tone', function () {
|
||||
$html = Blade::render('<x-ui.badge status="active">Aktiv</x-ui.badge>');
|
||||
|
||||
expect($html)->toContain('text-success')->toContain('Aktiv');
|
||||
});
|
||||
|
||||
it('renders a stat tile with a progress bar', function () {
|
||||
$html = Blade::render('<x-ui.stat-tile label="Speicher" value="172" percent="69" />');
|
||||
|
||||
expect($html)->toContain('Speicher')->toContain('width: 69%');
|
||||
});
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
it('redirects guests to the login page', function () {
|
||||
$this->get('/dashboard')->assertRedirect('/login');
|
||||
});
|
||||
|
||||
it('shows the dashboard to authenticated users', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)->get('/dashboard')
|
||||
->assertOk()
|
||||
->assertSee('CluPilot')
|
||||
->assertSee(__('dashboard.title'));
|
||||
});
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
it('renders the welcome page with a sign-in link once login exists', function () {
|
||||
$this->get('/')
|
||||
->assertOk()
|
||||
->assertSee('CluPilot')
|
||||
->assertSee(route('login'), false);
|
||||
});
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Test Case
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The closure you provide to your test functions is always bound to a specific PHPUnit test
|
||||
| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
|
||||
| need to change it using the "pest()" function to bind different classes or traits.
|
||||
|
|
||||
*/
|
||||
|
||||
pest()->extend(TestCase::class)
|
||||
->use(RefreshDatabase::class)
|
||||
->in('Feature');
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Expectations
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When you're writing tests, you often need to check that values meet certain conditions. The
|
||||
| "expect()" function gives you access to a set of "expectations" methods that you can use
|
||||
| to assert different things. Of course, you may extend the Expectation API at any time.
|
||||
|
|
||||
*/
|
||||
|
||||
expect()->extend('toBeOne', function () {
|
||||
return $this->toBe(1);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Functions
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
|
||||
| project that you don't want to repeat in every file. Here you can also expose helpers as
|
||||
| global functions to help you to reduce the number of lines of code in your test files.
|
||||
|
|
||||
*/
|
||||
|
||||
function something()
|
||||
{
|
||||
// ..
|
||||
}
|
||||
Loading…
Reference in New Issue