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
nexxo 2026-07-25 01:20:25 +02:00
parent 53c2a12d6d
commit 254a7d46a0
46 changed files with 3729 additions and 41 deletions

View File

@ -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']),
]);
}
}

View File

@ -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'];
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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')],
],
]);
}
}

View File

@ -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.

View File

@ -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()
);
});
}
}

View File

@ -1,7 +1,6 @@
<?php <?php
use App\Providers\AppServiceProvider;
return [ return [
AppServiceProvider::class, App\Providers\AppServiceProvider::class,
App\Providers\FortifyServiceProvider::class,
]; ];

View File

@ -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": {

2209
composer.lock generated

File diff suppressed because it is too large Load Diff

183
config/fortify.php Normal file
View File

@ -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([...]),
],
];

View File

@ -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',
]);
});
}
};

View File

@ -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');
}
};

View File

@ -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')),
],
);
} }
} }

26
lang/de/auth.php Normal file
View File

@ -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',
];

60
lang/de/dashboard.php Normal file
View File

@ -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',
],
];

26
lang/en/auth.php Normal file
View File

@ -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',
];

60
lang/en/dashboard.php Normal file
View File

@ -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',
],
];

View File

@ -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"/>

View File

@ -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) {

View File

@ -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());
}
},
}));
});

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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');
}); });

View File

@ -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();
});

View File

@ -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);
});

View File

@ -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%');
});

View File

@ -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'));
});

View File

@ -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);
});

50
tests/Pest.php Normal file
View File

@ -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()
{
// ..
}