Add touch-ready Filament login and admin update tooling

This commit is contained in:
soeren
2026-01-18 15:34:16 +01:00
parent 6f6ea8b24f
commit 30ca8082b3
31 changed files with 1940 additions and 1159 deletions

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Filament\Pages\Auth;
use Filament\Auth\Pages\EditProfile as BaseEditProfile;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Illuminate\Support\Facades\Hash;
class EditProfile extends BaseEditProfile
{
protected int $maxPinLength = 8;
public function form(Schema $schema): Schema
{
return $schema
->components([
$this->getNameFormComponent(),
$this->getEmailFormComponent(),
$this->getPasswordFormComponent(),
$this->getPasswordConfirmationFormComponent(),
$this->getCurrentPasswordFormComponent(),
$this->getAdminPinFormComponent(),
$this->getRemoveAdminPinFormComponent(),
]);
}
protected function getAdminPinFormComponent(): Component
{
return TextInput::make('admin_pin')
->label('Admin PIN')
->password()
->revealable()
->afterStateHydrated(function (TextInput $component): void {
$component->state(null);
})
->disabled(fn (Get $get): bool => (bool) $get('remove_admin_pin'))
->helperText('Leave blank to keep the current PIN. Use only digits.')
->rule('regex:/^\\d+$/')
->minLength(4)
->maxLength($this->maxPinLength);
}
protected function getRemoveAdminPinFormComponent(): Component
{
return Toggle::make('remove_admin_pin')
->label('Remove admin PIN')
->helperText('Clears the PIN so this user no longer appears on the kiosk login.')
->default(false);
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeSave(array $data): array
{
$pin = (string) ($data['admin_pin'] ?? '');
$removePin = (bool) ($data['remove_admin_pin'] ?? false);
if ($removePin) {
$data['admin_pin_hash'] = null;
} elseif ($pin !== '') {
$data['admin_pin_hash'] = Hash::make($pin);
}
unset($data['admin_pin'], $data['remove_admin_pin']);
return $data;
}
}

View File

@@ -0,0 +1,196 @@
<?php
namespace App\Filament\Pages\Auth;
use App\Models\User;
use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException;
use Filament\Auth\Http\Responses\Contracts\LoginResponse;
use Filament\Auth\Pages\Login as BaseLogin;
use Filament\Facades\Filament;
use Filament\Forms\Components\Hidden;
use Filament\Models\Contracts\FilamentUser;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\View;
use Filament\Schemas\Schema;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Hash;
class Login extends BaseLogin
{
protected int $maxPinLength = 8;
public function form(Schema $schema): Schema
{
return $schema
->components([
Hidden::make('email')
->rules(fn (): array => $this->shouldShowPinLogin() ? ['required', 'email'] : [])
->visible(fn (): bool => $this->shouldShowPinLogin())
->dehydrated(fn (): bool => $this->shouldShowPinLogin())
->live(),
Hidden::make('pin')
->rules(fn (): array => $this->shouldShowPinLogin() ? [
'nullable',
'min:4',
"max:{$this->maxPinLength}",
'regex:/^\\d+$/',
] : [])
->visible(fn (): bool => $this->shouldShowPinLogin())
->dehydrated(fn (): bool => $this->shouldShowPinLogin())
->live(),
$this->getEmailFormComponent(),
$this->getPasswordFormComponent(),
$this->getRememberFormComponent(),
View::make('filament.pages.auth.kiosk-login')
->viewData([
'hasPinUsers' => $this->shouldShowPinLogin(),
'users' => $this->getKioskUsers(),
'maxPinLength' => $this->maxPinLength,
])
->columnSpanFull(),
]);
}
public function authenticate(): ?LoginResponse
{
try {
$this->rateLimit(5);
} catch (TooManyRequestsException $exception) {
$this->getRateLimitedNotification($exception)?->send();
return null;
}
$data = $this->form->getState();
if (blank($data['pin'] ?? null)) {
return parent::authenticate();
}
$authGuard = Filament::auth();
$credentials = [
'email' => $data['email'] ?? null,
'password' => '',
];
$user = User::query()
->where('email', $data['email'] ?? '')
->first();
$pin = (string) ($data['pin'] ?? '');
if (
(! $user)
|| blank($user->admin_pin_hash)
|| $pin === ''
|| (! ctype_digit($pin))
|| (strlen($pin) < 4 || strlen($pin) > $this->maxPinLength)
|| (! Hash::check($pin, $user->admin_pin_hash))
) {
$this->userUndertakingMultiFactorAuthentication = null;
$this->fireFailedEvent($authGuard, $user, $credentials);
$this->throwFailureValidationException();
}
if (
filled($this->userUndertakingMultiFactorAuthentication) &&
(decrypt($this->userUndertakingMultiFactorAuthentication) === $user->getAuthIdentifier())
) {
$this->multiFactorChallengeForm->validate();
} else {
foreach (Filament::getMultiFactorAuthenticationProviders() as $multiFactorAuthenticationProvider) {
if (! $multiFactorAuthenticationProvider->isEnabled($user)) {
continue;
}
$this->userUndertakingMultiFactorAuthentication = encrypt($user->getAuthIdentifier());
if ($multiFactorAuthenticationProvider instanceof \Filament\Auth\MultiFactor\Contracts\HasBeforeChallengeHook) {
$multiFactorAuthenticationProvider->beforeChallenge($user);
}
break;
}
if (filled($this->userUndertakingMultiFactorAuthentication)) {
$this->multiFactorChallengeForm->fill();
return null;
}
}
if ($user instanceof FilamentUser) {
if (! $user->canAccessPanel(Filament::getCurrentOrDefaultPanel())) {
$this->fireFailedEvent($authGuard, $user, $credentials);
$this->throwFailureValidationException();
}
}
$authGuard->login($user, $data['remember'] ?? true);
session()->regenerate();
return app(LoginResponse::class);
}
public function selectUser(int $userId): void
{
$user = $this->getKioskUsers()->firstWhere('id', $userId);
if (! $user) {
return;
}
$this->data['email'] = $user->email;
$this->data['pin'] = '';
}
public function appendPinDigit(int $digit): void
{
$pin = (string) ($this->data['pin'] ?? '');
if (strlen($pin) >= $this->maxPinLength) {
return;
}
$this->data['pin'] = $pin.$digit;
}
public function deletePinDigit(): void
{
$pin = (string) ($this->data['pin'] ?? '');
if ($pin === '') {
return;
}
$this->data['pin'] = substr($pin, 0, -1);
}
public function clearPin(): void
{
$this->data['pin'] = '';
}
/**
* @return \Illuminate\Support\Collection<int, User>
*/
protected function getKioskUsers(): Collection
{
return User::query()
->whereNotNull('admin_pin_hash')
->orderBy('name')
->get(['id', 'name', 'email', 'admin_pin_hash']);
}
protected function shouldShowPinLogin(): bool
{
return $this->getKioskUsers()->isNotEmpty();
}
protected function getPasswordFormComponent(): Component
{
return parent::getPasswordFormComponent()
->required(fn (): bool => blank($this->data['pin'] ?? null));
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Filament\Resources\Users;
use App\Models\User;
use BackedEnum;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteBulkAction;
@@ -16,7 +17,6 @@ use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use UnitEnum;
use BackedEnum;
class UserResource extends Resource
{
@@ -50,6 +50,20 @@ class UserResource extends Resource
->dehydrated(fn (?string $state): bool => filled($state))
->required(fn (string $operation): bool => $operation === 'create')
->maxLength(255),
TextInput::make('admin_pin_hash')
->label('Admin PIN')
->password()
->revealable()
->dehydrateStateUsing(fn (?string $state): ?string => filled($state) ? bcrypt($state) : null)
->dehydrated(fn (?string $state): bool => filled($state))
->afterStateHydrated(function (TextInput $component): void {
$component->state(null);
})
->nullable()
->rule('regex:/^\\d+$/')
->minLength(4)
->maxLength(8)
->helperText('Leave blank to keep the current PIN.'),
Select::make('role_id')
->label(__('filament.resource.user.form.role'))
->relationship('role', 'name')

View File

@@ -23,6 +23,7 @@ class User extends Authenticatable implements FilamentUser
'name',
'email',
'password',
'admin_pin_hash',
'role_id',
'email_notifications_enabled',
'theme_preference',
@@ -41,6 +42,7 @@ class User extends Authenticatable implements FilamentUser
*/
protected $hidden = [
'password',
'admin_pin_hash',
'remember_token',
];

View File

@@ -32,7 +32,8 @@ class AdminPanelProvider extends PanelProvider
->default()
->id('admin')
->path('admin')
->login()
->viteTheme('resources/css/filament/admin/theme.css')
->login(\App\Filament\Pages\Auth\Login::class)
->brandLogo(fn () => new HtmlString(
'<img src="'.asset('icon.png').'" alt="App Icon" style="height: 2.5rem; display: inline-block; vertical-align: middle; margin-right: 0.5rem;" />'.
'<span style="vertical-align: middle; font-weight: bold; font-size: 1.25rem;">'.config('app.name').'</span>'
@@ -75,7 +76,7 @@ class AdminPanelProvider extends PanelProvider
->plugins([
])
->profile(isSimple: false)
->profile(\App\Filament\Pages\Auth\EditProfile::class, isSimple: false)
->userMenuItems([
MenuItem::make()
->label(fn () => 'English')