Add touch-ready Filament login and admin update tooling
This commit is contained in:
74
app/Filament/Pages/Auth/EditProfile.php
Normal file
74
app/Filament/Pages/Auth/EditProfile.php
Normal 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;
|
||||
}
|
||||
}
|
||||
196
app/Filament/Pages/Auth/Login.php
Normal file
196
app/Filament/Pages/Auth/Login.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user