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\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
|
|
||||||
#[Fillable(['name', 'email', 'password'])]
|
#[Fillable(['name', 'email', 'password'])]
|
||||||
#[Hidden(['password', 'remember_token'])]
|
#[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])]
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasFactory, Notifiable;
|
use HasFactory, Notifiable, TwoFactorAuthenticatable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the attributes that should be cast.
|
* 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
|
<?php
|
||||||
|
|
||||||
use App\Providers\AppServiceProvider;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
AppServiceProvider::class,
|
App\Providers\AppServiceProvider::class,
|
||||||
|
App\Providers\FortifyServiceProvider::class,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.3",
|
"php": "^8.3",
|
||||||
|
"laravel/fortify": "^1.37",
|
||||||
"laravel/framework": "^13.8",
|
"laravel/framework": "^13.8",
|
||||||
"laravel/reverb": "^1.11",
|
"laravel/reverb": "^1.11",
|
||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
|
|
@ -21,6 +22,8 @@
|
||||||
"laravel/pint": "^1.27",
|
"laravel/pint": "^1.27",
|
||||||
"mockery/mockery": "^1.6",
|
"mockery/mockery": "^1.6",
|
||||||
"nunomaduro/collision": "^8.6",
|
"nunomaduro/collision": "^8.6",
|
||||||
|
"pestphp/pest": "^4.7",
|
||||||
|
"pestphp/pest-plugin-livewire": "^4.1",
|
||||||
"phpunit/phpunit": "^12.5.12"
|
"phpunit/phpunit": "^12.5.12"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"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;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
class DatabaseSeeder extends Seeder
|
class DatabaseSeeder extends Seeder
|
||||||
{
|
{
|
||||||
use WithoutModelEvents;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seed the application's database.
|
* Seed the application's database.
|
||||||
*/
|
*/
|
||||||
public function run(): void
|
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([
|
User::updateOrCreate(
|
||||||
'name' => 'Test User',
|
['email' => env('SEED_ADMIN_EMAIL', 'admin@clupilot.local')],
|
||||||
'email' => 'test@example.com',
|
[
|
||||||
]);
|
'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>
|
</include>
|
||||||
</source>
|
</source>
|
||||||
<php>
|
<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="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
<env name="BCRYPT_ROUNDS" value="4" force="true"/>
|
||||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
<env name="BROADCAST_CONNECTION" value="null" force="true"/>
|
||||||
<env name="CACHE_STORE" value="array"/>
|
<env name="CACHE_STORE" value="array" force="true"/>
|
||||||
<env name="DB_CONNECTION" value="sqlite"/>
|
<env name="DB_CONNECTION" value="sqlite" force="true"/>
|
||||||
<env name="DB_DATABASE" value=":memory:"/>
|
<env name="DB_DATABASE" value=":memory:" force="true"/>
|
||||||
<env name="DB_URL" value=""/>
|
<env name="DB_URL" value="" force="true"/>
|
||||||
<env name="MAIL_MAILER" value="array"/>
|
<env name="MAIL_MAILER" value="array" force="true"/>
|
||||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
|
||||||
<env name="SESSION_DRIVER" value="array"/>
|
<env name="SESSION_DRIVER" value="array" force="true"/>
|
||||||
<env name="PULSE_ENABLED" value="false"/>
|
<env name="PULSE_ENABLED" value="false"/>
|
||||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,11 @@
|
||||||
.font-mono {
|
.font-mono {
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Alpine: hide elements until initialised. */
|
||||||
|
[x-cloak] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@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';
|
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
|
<?php
|
||||||
|
|
||||||
|
use App\Livewire\Auth\Login;
|
||||||
|
use App\Livewire\Auth\TwoFactorChallenge;
|
||||||
|
use App\Livewire\Dashboard;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/', function () {
|
Route::get('/', fn () => view('welcome'))->name('home');
|
||||||
return view('welcome');
|
|
||||||
|
// 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