Compare commits

..

No commits in common. "82079b21d5dc1c1c7c3dde40d1d8fa939e702c94" and "c94dd893614efaeec1f0a1d0d69db3afe7e274b9" have entirely different histories.

109 changed files with 1369 additions and 2827 deletions

View file

@ -10,10 +10,7 @@ RUN apk add --no-cache composer
RUN apk add --no-cache \ RUN apk add --no-cache \
imagemagick-dev \ imagemagick-dev \
chromium \ chromium \
libzip-dev \ libzip-dev
freetype-dev \
libpng-dev \
libjpeg-turbo-dev
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
ENV PUPPETEER_DOCKER=1 ENV PUPPETEER_DOCKER=1
@ -22,10 +19,8 @@ RUN mkdir -p /usr/src/php/ext/imagick
RUN chmod 777 /usr/src/php/ext/imagick RUN chmod 777 /usr/src/php/ext/imagick
RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1 RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1
RUN docker-php-ext-configure gd --with-freetype --with-jpeg
# Install PHP extensions # Install PHP extensions
RUN docker-php-ext-install imagick zip gd RUN docker-php-ext-install imagick zip
# Composer uses its php binary, but we want it to use the container's one # Composer uses its php binary, but we want it to use the container's one
RUN rm -f /usr/bin/php84 RUN rm -f /usr/bin/php84

View file

@ -15,11 +15,7 @@ RUN apk add --no-cache \
npm \ npm \
imagemagick-dev \ imagemagick-dev \
chromium \ chromium \
libzip-dev \ libzip-dev
freetype-dev \
libpng-dev \
libjpeg-turbo-dev
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
ENV PUPPETEER_DOCKER=1 ENV PUPPETEER_DOCKER=1
@ -28,10 +24,8 @@ RUN mkdir -p /usr/src/php/ext/imagick
RUN chmod 777 /usr/src/php/ext/imagick RUN chmod 777 /usr/src/php/ext/imagick
RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1 RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1
RUN docker-php-ext-configure gd --with-freetype --with-jpeg
# Install PHP extensions # Install PHP extensions
RUN docker-php-ext-install imagick zip gd RUN docker-php-ext-install imagick zip
RUN rm -f /usr/bin/php84 RUN rm -f /usr/bin/php84
RUN ln -s /usr/local/bin/php /usr/bin/php84 RUN ln -s /usr/local/bin/php /usr/bin/php84

View file

@ -1,33 +0,0 @@
<?php
namespace App\Actions\Fortify;
use App\Concerns\PasswordValidationRules;
use App\Concerns\ProfileValidationRules;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules, ProfileValidationRules;
/**
* Validate and create a newly registered user.
*
* @param array<string, string> $input
*/
public function create(array $input): User
{
Validator::make($input, [
...$this->profileRules(),
'password' => $this->passwordRules(),
])->validate();
return User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => $input['password'],
]);
}
}

View file

@ -1,29 +0,0 @@
<?php
namespace App\Actions\Fortify;
use App\Concerns\PasswordValidationRules;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
{
use PasswordValidationRules;
/**
* Validate and reset the user's forgotten password.
*
* @param array<string, string> $input
*/
public function reset(User $user, array $input): void
{
Validator::make($input, [
'password' => $this->passwordRules(),
])->validate();
$user->forceFill([
'password' => $input['password'],
])->save();
}
}

View file

@ -1,28 +0,0 @@
<?php
namespace App\Concerns;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
*/
protected function passwordRules(): array
{
return ['required', 'string', Password::default(), 'confirmed'];
}
/**
* Get the validation rules used to validate the current password.
*
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
*/
protected function currentPasswordRules(): array
{
return ['required', 'string', 'current_password'];
}
}

View file

@ -1,50 +0,0 @@
<?php
namespace App\Concerns;
use App\Models\User;
use Illuminate\Validation\Rule;
trait ProfileValidationRules
{
/**
* Get the validation rules used to validate user profiles.
*
* @return array<string, array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>>
*/
protected function profileRules(?int $userId = null): array
{
return [
'name' => $this->nameRules(),
'email' => $this->emailRules($userId),
];
}
/**
* Get the validation rules used to validate user names.
*
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
*/
protected function nameRules(): array
{
return ['required', 'string', 'max:255'];
}
/**
* Get the validation rules used to validate user emails.
*
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
*/
protected function emailRules(?int $userId = null): array
{
return [
'required',
'string',
'email',
'max:255',
$userId === null
? Rule::unique(User::class)
: Rule::unique(User::class)->ignore($userId),
];
}
}

View file

@ -1,24 +0,0 @@
<?php
namespace App\Facades;
use Illuminate\Support\Facades\Facade;
/**
* @method static \App\Services\QrCodeService format(string $format)
* @method static \App\Services\QrCodeService size(int $size)
* @method static \App\Services\QrCodeService errorCorrection(string $level)
* @method static string generate(string $text)
*
* @see \App\Services\QrCodeService
*/
class QrCode extends Facade
{
/**
* Get the registered name of the component.
*/
protected static function getFacadeAccessor(): string
{
return 'qr-code';
}
}

View file

@ -2,12 +2,12 @@
namespace App\Liquid\Filters; namespace App\Liquid\Filters;
use App\Facades\QrCode;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Keepsuit\Liquid\Filters\FiltersProvider; use Keepsuit\Liquid\Filters\FiltersProvider;
use League\CommonMark\CommonMarkConverter; use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Exception\CommonMarkException; use League\CommonMark\Exception\CommonMarkException;
use SimpleSoftwareIO\QrCode\Facades\QrCode;
/** /**
* String, Markup, and HTML filters for Liquid templates * String, Markup, and HTML filters for Liquid templates
@ -73,7 +73,7 @@ class StringMarkup extends FiltersProvider
// Default module_size is 11 // Default module_size is 11
// Size calculation: (21 modules for QR code + 4 modules margin on each side * 2) * module_size // Size calculation: (21 modules for QR code + 4 modules margin on each side * 2) * module_size
// = (21 + 8) * module_size = 29 * module_size // = (21 + 8) * module_size = 29 * module_size
$moduleSize ??= 11; $moduleSize = $moduleSize ?? 11;
$size = 29 * $moduleSize; $size = 29 * $moduleSize;
$qrCode = QrCode::format('svg') $qrCode = QrCode::format('svg')
@ -84,6 +84,25 @@ class StringMarkup extends FiltersProvider
$qrCode->errorCorrection($errorCorrection); $qrCode->errorCorrection($errorCorrection);
} }
return $qrCode->generate($text); $svg = (string) $qrCode->generate($text);
// Add class="qr-code" to the SVG element
// The SVG may start with <?xml...> and then <svg, so we need to find the <svg tag
// Match <svg followed by whitespace or attributes, and insert class before the first attribute or closing >
if (preg_match('/<svg\s+([^>]*)>/', $svg, $matches)) {
$attributes = $matches[1];
// Check if class already exists
if (mb_strpos($attributes, 'class=') === false) {
$svg = preg_replace('/<svg\s+([^>]*)>/', '<svg class="qr-code" $1>', $svg, 1);
} else {
// If class exists, add qr-code to it
$svg = preg_replace('/(<svg\s+[^>]*class=["\'])([^"\']*)(["\'][^>]*>)/', '$1$2 qr-code$3', $svg, 1);
}
} else {
// Fallback: simple replacement if no attributes
$svg = preg_replace('/<svg>/', '<svg class="qr-code">', $svg, 1);
}
return $svg;
} }
} }

View file

@ -12,7 +12,7 @@ class DeviceAutoJoin extends Component
public function mount(): void public function mount(): void
{ {
$this->deviceAutojoin = (bool) (auth()->user()->assign_new_devices ?? false); $this->deviceAutojoin = auth()->user()->assign_new_devices;
$this->isFirstUser = auth()->user()->id === 1; $this->isFirstUser = auth()->user()->id === 1;
} }

View file

@ -174,8 +174,8 @@ class Plugin extends Model
// resolve and clean URLs // resolve and clean URLs
$resolvedPollingUrls = $this->resolveLiquidVariables($this->polling_url); $resolvedPollingUrls = $this->resolveLiquidVariables($this->polling_url);
$urls = array_values(array_filter( // array_values ensures 0, 1, 2... $urls = array_values(array_filter( // array_values ensures 0, 1, 2...
array_map(trim(...), explode("\n", $resolvedPollingUrls)), array_map('trim', explode("\n", $resolvedPollingUrls)),
filled(...) fn ($url): bool => filled($url)
)); ));
$combinedResponse = []; $combinedResponse = [];
@ -624,7 +624,7 @@ class Plugin extends Model
// File doesn't exist, remove the view reference // File doesn't exist, remove the view reference
$attributes['render_markup_view'] = null; $attributes['render_markup_view'] = null;
} }
} catch (Exception) { } catch (Exception $e) {
// If file reading fails, remove the view reference // If file reading fails, remove the view reference
$attributes['render_markup_view'] = null; $attributes['render_markup_view'] = null;
} }

View file

@ -8,13 +8,12 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable // implements MustVerifyEmail class User extends Authenticatable // implements MustVerifyEmail
{ {
/** @use HasFactory<\Database\Factories\UserFactory> */ /** @use HasFactory<\Database\Factories\UserFactory> */
use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable; use HasApiTokens, HasFactory, Notifiable;
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.

View file

@ -3,7 +3,6 @@
namespace App\Providers; namespace App\Providers;
use App\Services\OidcProvider; use App\Services\OidcProvider;
use App\Services\QrCodeService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
@ -16,7 +15,7 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function register(): void public function register(): void
{ {
$this->app->bind('qr-code', fn () => new QrCodeService); //
} }
/** /**

View file

@ -1,72 +0,0 @@
<?php
namespace App\Providers;
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Contracts\View\Factory;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
$this->configureActions();
$this->configureViews();
$this->configureRateLimiting();
}
/**
* Configure Fortify actions.
*/
private function configureActions(): void
{
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
Fortify::createUsersUsing(CreateNewUser::class);
}
/**
* Configure Fortify views.
*/
private function configureViews(): void
{
Fortify::loginView(fn (): Factory|View => view('pages::auth.login'));
Fortify::verifyEmailView(fn (): Factory|View => view('pages::auth.verify-email'));
Fortify::twoFactorChallengeView(fn (): Factory|View => view('pages::auth.two-factor-challenge'));
Fortify::confirmPasswordView(fn (): Factory|View => view('pages::auth.confirm-password'));
Fortify::registerView(fn (): Factory|View => view('pages::auth.register'));
Fortify::resetPasswordView(fn (): Factory|View => view('pages::auth.reset-password'));
Fortify::requestPasswordResetLinkView(fn (): Factory|View => view('pages::auth.forgot-password'));
}
/**
* Configure rate limiting.
*/
private function configureRateLimiting(): void
{
RateLimiter::for('two-factor', fn (Request $request) => Limit::perMinute(5)->by($request->session()->get('login.id')));
RateLimiter::for('login', function (Request $request) {
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
return Limit::perMinute(5)->by($throttleKey);
});
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Livewire\Volt\Volt;
class VoltServiceProvider extends ServiceProvider
{
/**
* Register services.
*/
public function register(): void
{
//
}
/**
* Bootstrap services.
*/
public function boot(): void
{
Volt::mount([
config('livewire.view_path', resource_path('views/livewire')),
resource_path('views/pages'),
]);
}
}

View file

@ -61,9 +61,9 @@ class ImageGenerationService
try { try {
// Get image generation settings from DeviceModel or Device (for legacy devices) // Get image generation settings from DeviceModel or Device (for legacy devices)
$imageSettings = $deviceModel instanceof DeviceModel $imageSettings = $deviceModel
? self::getImageSettingsFromModel($deviceModel) ? self::getImageSettingsFromModel($deviceModel)
: ($device instanceof Device ? self::getImageSettings($device) : self::getImageSettingsFromModel(null)); : ($device ? self::getImageSettings($device) : self::getImageSettingsFromModel(null));
$fileExtension = $imageSettings['mime_type'] === 'image/bmp' ? 'bmp' : 'png'; $fileExtension = $imageSettings['mime_type'] === 'image/bmp' ? 'bmp' : 'png';
$outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension); $outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension);
@ -78,7 +78,7 @@ class ImageGenerationService
$browserStage->html($markup); $browserStage->html($markup);
// Set timezone from user or fall back to app timezone // Set timezone from user or fall back to app timezone
$timezone = $user->timezone ?? config('app.timezone'); $timezone = $user?->timezone ?? config('app.timezone');
$browserStage->timezone($timezone); $browserStage->timezone($timezone);
if (config('app.puppeteer_window_size_strategy') === 'v2') { if (config('app.puppeteer_window_size_strategy') === 'v2') {
@ -186,7 +186,7 @@ class ImageGenerationService
*/ */
private static function getImageSettingsFromModel(?DeviceModel $deviceModel): array private static function getImageSettingsFromModel(?DeviceModel $deviceModel): array
{ {
if ($deviceModel instanceof DeviceModel) { if ($deviceModel) {
return [ return [
'width' => $deviceModel->width, 'width' => $deviceModel->width,
'height' => $deviceModel->height, 'height' => $deviceModel->height,

View file

@ -33,11 +33,11 @@ class PluginImportService
foreach ($settings['custom_fields'] as $field) { foreach ($settings['custom_fields'] as $field) {
if (isset($field['field_type']) && $field['field_type'] === 'multi_string') { if (isset($field['field_type']) && $field['field_type'] === 'multi_string') {
if (isset($field['default']) && str_contains((string) $field['default'], ',')) { if (isset($field['default']) && str_contains($field['default'], ',')) {
throw new Exception("Validation Error: The default value for multistring fields like `{$field['keyname']}` cannot contain commas."); throw new Exception("Validation Error: The default value for multistring fields like `{$field['keyname']}` cannot contain commas.");
} }
if (isset($field['placeholder']) && str_contains((string) $field['placeholder'], ',')) { if (isset($field['placeholder']) && str_contains($field['placeholder'], ',')) {
throw new Exception("Validation Error: The placeholder value for multistring fields like `{$field['keyname']}` cannot contain commas."); throw new Exception("Validation Error: The placeholder value for multistring fields like `{$field['keyname']}` cannot contain commas.");
} }
@ -159,7 +159,7 @@ class PluginImportService
: null, : null,
'polling_body' => $settings['polling_body'] ?? null, 'polling_body' => $settings['polling_body'] ?? null,
'markup_language' => $markupLanguage, 'markup_language' => $markupLanguage,
'render_markup' => $fullLiquid ?? null, 'render_markup' => $fullLiquid,
'configuration_template' => $configurationTemplate, 'configuration_template' => $configurationTemplate,
'data_payload' => json_decode($settings['static_data'] ?? '{}', true), 'data_payload' => json_decode($settings['static_data'] ?? '{}', true),
]); ]);
@ -321,7 +321,7 @@ class PluginImportService
: null, : null,
'polling_body' => $settings['polling_body'] ?? null, 'polling_body' => $settings['polling_body'] ?? null,
'markup_language' => $markupLanguage, 'markup_language' => $markupLanguage,
'render_markup' => $fullLiquid ?? null, 'render_markup' => $fullLiquid,
'configuration_template' => $configurationTemplate, 'configuration_template' => $configurationTemplate,
'data_payload' => json_decode($settings['static_data'] ?? '{}', true), 'data_payload' => json_decode($settings['static_data'] ?? '{}', true),
'preferred_renderer' => $preferredRenderer, 'preferred_renderer' => $preferredRenderer,

View file

@ -1,147 +0,0 @@
<?php
namespace App\Services;
use BaconQrCode\Common\ErrorCorrectionLevel;
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
use InvalidArgumentException;
/**
* QR Code generation service using bacon/bacon-qr-code
*/
class QrCodeService
{
protected ?string $format = null;
protected ?int $size = null;
protected ?string $errorCorrection = null;
/**
* Set the output format
*
* @param string $format The format (currently only 'svg' is supported)
* @return $this
*/
public function format(string $format): self
{
$this->format = $format;
return $this;
}
/**
* Set the size of the QR code
*
* @param int $size The size in pixels
* @return $this
*/
public function size(int $size): self
{
$this->size = $size;
return $this;
}
/**
* Set the error correction level
*
* @param string $level Error correction level: 'l', 'm', 'q', 'h'
* @return $this
*/
public function errorCorrection(string $level): self
{
$this->errorCorrection = $level;
return $this;
}
/**
* Generate the QR code
*
* @param string $text The text to encode
* @return string The generated QR code (SVG string)
*/
public function generate(string $text): string
{
// Ensure format is set (default to SVG)
$format = $this->format ?? 'svg';
if ($format !== 'svg') {
throw new InvalidArgumentException("Format '{$format}' is not supported. Only 'svg' is currently supported.");
}
// Calculate size and margin
// If size is not set, calculate from module size (default module size is 11)
if ($this->size === null) {
$moduleSize = 11;
$this->size = 29 * $moduleSize;
}
// Calculate margin: 4 modules on each side
// Module size = size / 29, so margin = (size / 29) * 4
$moduleSize = $this->size / 29;
$margin = (int) ($moduleSize * 4);
// Map error correction level
$errorCorrectionLevel = ErrorCorrectionLevel::valueOf('M'); // default
if ($this->errorCorrection !== null) {
$errorCorrectionLevel = match (mb_strtoupper($this->errorCorrection)) {
'L' => ErrorCorrectionLevel::valueOf('L'),
'M' => ErrorCorrectionLevel::valueOf('M'),
'Q' => ErrorCorrectionLevel::valueOf('Q'),
'H' => ErrorCorrectionLevel::valueOf('H'),
default => ErrorCorrectionLevel::valueOf('M'),
};
}
// Create renderer style with size and margin
$rendererStyle = new RendererStyle($this->size, $margin);
// Create SVG renderer
$renderer = new ImageRenderer(
$rendererStyle,
new SvgImageBackEnd()
);
// Create writer
$writer = new Writer($renderer);
// Generate SVG
$svg = $writer->writeString($text, 'ISO-8859-1', $errorCorrectionLevel);
// Add class="qr-code" to the SVG element
$svg = $this->addQrCodeClass($svg);
return $svg;
}
/**
* Add the 'qr-code' class to the SVG element
*
* @param string $svg The SVG string
* @return string The SVG string with the class added
*/
protected function addQrCodeClass(string $svg): string
{
// Match <svg followed by whitespace or attributes, and insert class before the first attribute or closing >
if (preg_match('/<svg\s+([^>]*)>/', $svg, $matches)) {
$attributes = $matches[1];
// Check if class already exists
if (mb_strpos($attributes, 'class=') === false) {
$svg = preg_replace('/<svg\s+([^>]*)>/', '<svg class="qr-code" $1>', $svg, 1);
} else {
// If class exists, add qr-code to it
$svg = preg_replace('/(<svg\s+[^>]*class=["\'])([^"\']*)(["\'][^>]*>)/', '$1$2 qr-code$3', $svg, 1);
}
} else {
// Fallback: simple replacement if no attributes
$svg = preg_replace('/<svg>/', '<svg class="qr-code">', $svg, 1);
}
return $svg;
}
}

View file

@ -2,5 +2,6 @@
return [ return [
App\Providers\AppServiceProvider::class, App\Providers\AppServiceProvider::class,
App\Providers\FortifyServiceProvider::class, App\Providers\FolioServiceProvider::class,
App\Providers\VoltServiceProvider::class,
]; ];

View file

@ -18,14 +18,15 @@
"bnussbau/laravel-trmnl-blade": "2.1.*", "bnussbau/laravel-trmnl-blade": "2.1.*",
"bnussbau/trmnl-pipeline-php": "^0.6.0", "bnussbau/trmnl-pipeline-php": "^0.6.0",
"keepsuit/laravel-liquid": "^0.5.2", "keepsuit/laravel-liquid": "^0.5.2",
"laravel/fortify": "^1.30",
"laravel/framework": "^12.1", "laravel/framework": "^12.1",
"laravel/sanctum": "^4.0", "laravel/sanctum": "^4.0",
"laravel/socialite": "^5.23", "laravel/socialite": "^5.23",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
"livewire/livewire": "^3.7",
"livewire/flux": "^2.0", "livewire/flux": "^2.0",
"livewire/livewire": "^4.0", "livewire/volt": "^1.7",
"om/icalparser": "^3.2", "om/icalparser": "^3.2",
"simplesoftwareio/simple-qrcode": "^4.2",
"spatie/browsershot": "^5.0", "spatie/browsershot": "^5.0",
"stevebauman/purify": "^6.3", "stevebauman/purify": "^6.3",
"symfony/yaml": "^7.3", "symfony/yaml": "^7.3",

315
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "cd2e6e87598cd99111e73d9ce8e0a9b8", "content-hash": "ac6b1e352cb66f858a50b64e7e3c70d0",
"packages": [ "packages": [
{ {
"name": "aws/aws-crt-php", "name": "aws/aws-crt-php",
@ -62,16 +62,16 @@
}, },
{ {
"name": "aws/aws-sdk-php", "name": "aws/aws-sdk-php",
"version": "3.369.14", "version": "3.369.13",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/aws/aws-sdk-php.git", "url": "https://github.com/aws/aws-sdk-php.git",
"reference": "b40eb1177d2e621c18cd797ca6cc9efb5a0e99d9" "reference": "bedc36250c92b8287be855a2d25427fb0e065483"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/b40eb1177d2e621c18cd797ca6cc9efb5a0e99d9", "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/bedc36250c92b8287be855a2d25427fb0e065483",
"reference": "b40eb1177d2e621c18cd797ca6cc9efb5a0e99d9", "reference": "bedc36250c92b8287be855a2d25427fb0e065483",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -153,35 +153,34 @@
"support": { "support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions", "forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues", "issues": "https://github.com/aws/aws-sdk-php/issues",
"source": "https://github.com/aws/aws-sdk-php/tree/3.369.14" "source": "https://github.com/aws/aws-sdk-php/tree/3.369.13"
}, },
"time": "2026-01-15T19:10:54+00:00" "time": "2026-01-14T19:13:46+00:00"
}, },
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
"version": "v3.0.3", "version": "2.0.8",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/Bacon/BaconQrCode.git", "url": "https://github.com/Bacon/BaconQrCode.git",
"reference": "36a1cb2b81493fa5b82e50bf8068bf84d1542563" "reference": "8674e51bb65af933a5ffaf1c308a660387c35c22"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/36a1cb2b81493fa5b82e50bf8068bf84d1542563", "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/8674e51bb65af933a5ffaf1c308a660387c35c22",
"reference": "36a1cb2b81493fa5b82e50bf8068bf84d1542563", "reference": "8674e51bb65af933a5ffaf1c308a660387c35c22",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"dasprid/enum": "^1.0.3", "dasprid/enum": "^1.0.3",
"ext-iconv": "*", "ext-iconv": "*",
"php": "^8.1" "php": "^7.1 || ^8.0"
}, },
"require-dev": { "require-dev": {
"phly/keep-a-changelog": "^2.12", "phly/keep-a-changelog": "^2.1",
"phpunit/phpunit": "^10.5.11 || ^11.0.4", "phpunit/phpunit": "^7 | ^8 | ^9",
"spatie/phpunit-snapshot-assertions": "^5.1.5", "spatie/phpunit-snapshot-assertions": "^4.2.9",
"spatie/pixelmatch-php": "^1.2.0", "squizlabs/php_codesniffer": "^3.4"
"squizlabs/php_codesniffer": "^3.9"
}, },
"suggest": { "suggest": {
"ext-imagick": "to generate QR code images" "ext-imagick": "to generate QR code images"
@ -208,9 +207,9 @@
"homepage": "https://github.com/Bacon/BaconQrCode", "homepage": "https://github.com/Bacon/BaconQrCode",
"support": { "support": {
"issues": "https://github.com/Bacon/BaconQrCode/issues", "issues": "https://github.com/Bacon/BaconQrCode/issues",
"source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.3" "source": "https://github.com/Bacon/BaconQrCode/tree/2.0.8"
}, },
"time": "2025-11-19T17:15:36+00:00" "time": "2022-12-07T17:46:57+00:00"
}, },
{ {
"name": "bnussbau/laravel-trmnl-blade", "name": "bnussbau/laravel-trmnl-blade",
@ -1781,69 +1780,6 @@
}, },
"time": "2025-12-01T12:01:51+00:00" "time": "2025-12-01T12:01:51+00:00"
}, },
{
"name": "laravel/fortify",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/fortify.git",
"reference": "e0666dabeec0b6428678af1d51f436dcfb24e3a9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/fortify/zipball/e0666dabeec0b6428678af1d51f436dcfb24e3a9",
"reference": "e0666dabeec0b6428678af1d51f436dcfb24e3a9",
"shasum": ""
},
"require": {
"bacon/bacon-qr-code": "^3.0",
"ext-json": "*",
"illuminate/support": "^10.0|^11.0|^12.0",
"php": "^8.1",
"pragmarx/google2fa": "^9.0",
"symfony/console": "^6.0|^7.0"
},
"require-dev": {
"orchestra/testbench": "^8.36|^9.15|^10.8",
"phpstan/phpstan": "^1.10"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Fortify\\FortifyServiceProvider"
]
},
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\Fortify\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Backend controllers and scaffolding for Laravel authentication.",
"keywords": [
"auth",
"laravel"
],
"support": {
"issues": "https://github.com/laravel/fortify/issues",
"source": "https://github.com/laravel/fortify"
},
"time": "2025-12-15T14:48:33+00:00"
},
{ {
"name": "laravel/framework", "name": "laravel/framework",
"version": "v12.47.0", "version": "v12.47.0",
@ -2068,16 +2004,16 @@
}, },
{ {
"name": "laravel/prompts", "name": "laravel/prompts",
"version": "v0.3.10", "version": "v0.3.9",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/prompts.git", "url": "https://github.com/laravel/prompts.git",
"reference": "360ba095ef9f51017473505191fbd4ab73e1cab3" "reference": "5c41bf0555b7cfefaad4e66d3046675829581ac4"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/prompts/zipball/360ba095ef9f51017473505191fbd4ab73e1cab3", "url": "https://api.github.com/repos/laravel/prompts/zipball/5c41bf0555b7cfefaad4e66d3046675829581ac4",
"reference": "360ba095ef9f51017473505191fbd4ab73e1cab3", "reference": "5c41bf0555b7cfefaad4e66d3046675829581ac4",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -2121,9 +2057,9 @@
"description": "Add beautiful and user-friendly forms to your command-line applications.", "description": "Add beautiful and user-friendly forms to your command-line applications.",
"support": { "support": {
"issues": "https://github.com/laravel/prompts/issues", "issues": "https://github.com/laravel/prompts/issues",
"source": "https://github.com/laravel/prompts/tree/v0.3.10" "source": "https://github.com/laravel/prompts/tree/v0.3.9"
}, },
"time": "2026-01-13T20:29:29+00:00" "time": "2026-01-07T21:00:29+00:00"
}, },
{ {
"name": "laravel/sanctum", "name": "laravel/sanctum",
@ -3146,16 +3082,16 @@
}, },
{ {
"name": "livewire/livewire", "name": "livewire/livewire",
"version": "v4.0.1", "version": "v3.7.4",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/livewire/livewire.git", "url": "https://github.com/livewire/livewire.git",
"reference": "c7539589d5af82691bef17da17ce4e289269f8d9" "reference": "5a8dffd4c0ab357ff7ed5b39e7c2453d962a68e0"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/livewire/livewire/zipball/c7539589d5af82691bef17da17ce4e289269f8d9", "url": "https://api.github.com/repos/livewire/livewire/zipball/5a8dffd4c0ab357ff7ed5b39e7c2453d962a68e0",
"reference": "c7539589d5af82691bef17da17ce4e289269f8d9", "reference": "5a8dffd4c0ab357ff7ed5b39e7c2453d962a68e0",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -3210,7 +3146,7 @@
"description": "A front-end framework for Laravel.", "description": "A front-end framework for Laravel.",
"support": { "support": {
"issues": "https://github.com/livewire/livewire/issues", "issues": "https://github.com/livewire/livewire/issues",
"source": "https://github.com/livewire/livewire/tree/v4.0.1" "source": "https://github.com/livewire/livewire/tree/v3.7.4"
}, },
"funding": [ "funding": [
{ {
@ -3218,7 +3154,78 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-01-14T18:40:41+00:00" "time": "2026-01-13T09:37:21+00:00"
},
{
"name": "livewire/volt",
"version": "v1.10.1",
"source": {
"type": "git",
"url": "https://github.com/livewire/volt.git",
"reference": "48cff133990c6261c63ee279fc091af6f6c6654e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/livewire/volt/zipball/48cff133990c6261c63ee279fc091af6f6c6654e",
"reference": "48cff133990c6261c63ee279fc091af6f6c6654e",
"shasum": ""
},
"require": {
"laravel/framework": "^10.38.2|^11.0|^12.0",
"livewire/livewire": "^3.6.1|^4.0",
"php": "^8.1"
},
"require-dev": {
"laravel/folio": "^1.1",
"orchestra/testbench": "^8.36|^9.15|^10.8",
"pestphp/pest": "^2.9.5|^3.0|^4.0",
"phpstan/phpstan": "^1.10"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Livewire\\Volt\\VoltServiceProvider"
]
},
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"files": [
"functions.php"
],
"psr-4": {
"Livewire\\Volt\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
},
{
"name": "Nuno Maduro",
"email": "nuno@laravel.com"
}
],
"description": "An elegantly crafted functional API for Laravel Livewire.",
"homepage": "https://github.com/livewire/volt",
"keywords": [
"laravel",
"livewire",
"volt"
],
"support": {
"issues": "https://github.com/livewire/volt/issues",
"source": "https://github.com/livewire/volt"
},
"time": "2025-11-25T16:19:15+00:00"
}, },
{ {
"name": "maennchen/zipstream-php", "name": "maennchen/zipstream-php",
@ -4226,58 +4233,6 @@
], ],
"time": "2025-12-15T11:51:42+00:00" "time": "2025-12-15T11:51:42+00:00"
}, },
{
"name": "pragmarx/google2fa",
"version": "v9.0.0",
"source": {
"type": "git",
"url": "https://github.com/antonioribeiro/google2fa.git",
"reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/e6bc62dd6ae83acc475f57912e27466019a1f2cf",
"reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf",
"shasum": ""
},
"require": {
"paragonie/constant_time_encoding": "^1.0|^2.0|^3.0",
"php": "^7.1|^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.9",
"phpunit/phpunit": "^7.5.15|^8.5|^9.0"
},
"type": "library",
"autoload": {
"psr-4": {
"PragmaRX\\Google2FA\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Antonio Carlos Ribeiro",
"email": "acr@antoniocarlosribeiro.com",
"role": "Creator & Designer"
}
],
"description": "A One Time Password Authentication package, compatible with Google Authenticator.",
"keywords": [
"2fa",
"Authentication",
"Two Factor Authentication",
"google2fa"
],
"support": {
"issues": "https://github.com/antonioribeiro/google2fa/issues",
"source": "https://github.com/antonioribeiro/google2fa/tree/v9.0.0"
},
"time": "2025-09-19T22:51:08+00:00"
},
{ {
"name": "psr/clock", "name": "psr/clock",
"version": "1.0.0", "version": "1.0.0",
@ -4967,6 +4922,74 @@
}, },
"time": "2025-12-14T04:43:48+00:00" "time": "2025-12-14T04:43:48+00:00"
}, },
{
"name": "simplesoftwareio/simple-qrcode",
"version": "4.2.0",
"source": {
"type": "git",
"url": "https://github.com/SimpleSoftwareIO/simple-qrcode.git",
"reference": "916db7948ca6772d54bb617259c768c9cdc8d537"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/SimpleSoftwareIO/simple-qrcode/zipball/916db7948ca6772d54bb617259c768c9cdc8d537",
"reference": "916db7948ca6772d54bb617259c768c9cdc8d537",
"shasum": ""
},
"require": {
"bacon/bacon-qr-code": "^2.0",
"ext-gd": "*",
"php": ">=7.2|^8.0"
},
"require-dev": {
"mockery/mockery": "~1",
"phpunit/phpunit": "~9"
},
"suggest": {
"ext-imagick": "Allows the generation of PNG QrCodes.",
"illuminate/support": "Allows for use within Laravel."
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"QrCode": "SimpleSoftwareIO\\QrCode\\Facades\\QrCode"
},
"providers": [
"SimpleSoftwareIO\\QrCode\\QrCodeServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"SimpleSoftwareIO\\QrCode\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Simple Software LLC",
"email": "support@simplesoftware.io"
}
],
"description": "Simple QrCode is a QR code generator made for Laravel.",
"homepage": "https://www.simplesoftware.io/#/docs/simple-qrcode",
"keywords": [
"Simple",
"generator",
"laravel",
"qrcode",
"wrapper"
],
"support": {
"issues": "https://github.com/SimpleSoftwareIO/simple-qrcode/issues",
"source": "https://github.com/SimpleSoftwareIO/simple-qrcode/tree/4.2.0"
},
"time": "2021-02-08T20:43:55+00:00"
},
{ {
"name": "spatie/browsershot", "name": "spatie/browsershot",
"version": "5.2.0", "version": "5.2.0",

View file

@ -104,7 +104,7 @@ return [
| Password Confirmation Timeout | Password Confirmation Timeout
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| Here you may define the number of seconds before a password confirmation | Here you may define the amount of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the | window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours. | confirmation screen. By default, the timeout lasts for three hours.
| |

View file

@ -27,8 +27,7 @@ return [
| same cache driver to group types of items stored in your caches. | same cache driver to group types of items stored in your caches.
| |
| Supported drivers: "array", "database", "file", "memcached", | Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "octane", | "redis", "dynamodb", "octane", "null"
| "failover", "null"
| |
*/ */
@ -91,14 +90,6 @@ return [
'driver' => 'octane', 'driver' => 'octane',
], ],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
], ],
/* /*
@ -112,6 +103,6 @@ return [
| |
*/ */
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'), 'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'),
]; ];

View file

@ -40,7 +40,6 @@ return [
'busy_timeout' => null, 'busy_timeout' => null,
'journal_mode' => null, 'journal_mode' => null,
'synchronous' => null, 'synchronous' => null,
'transaction_mode' => 'DEFERRED',
], ],
'mysql' => [ 'mysql' => [
@ -59,7 +58,7 @@ return [
'strict' => true, 'strict' => true,
'engine' => null, 'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([ 'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? Pdo\Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [], ]) : [],
], ],
@ -79,7 +78,7 @@ return [
'strict' => true, 'strict' => true,
'engine' => null, 'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([ 'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? Pdo\Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [], ]) : [],
], ],
@ -95,7 +94,7 @@ return [
'prefix' => '', 'prefix' => '',
'prefix_indexes' => true, 'prefix_indexes' => true,
'search_path' => 'public', 'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'), 'sslmode' => 'prefer',
], ],
'sqlsrv' => [ 'sqlsrv' => [
@ -148,7 +147,7 @@ return [
'options' => [ 'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'), 'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'), 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'),
'persistent' => env('REDIS_PERSISTENT', false), 'persistent' => env('REDIS_PERSISTENT', false),
], ],
@ -159,10 +158,6 @@ return [
'password' => env('REDIS_PASSWORD'), 'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'), 'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'), 'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
], ],
'cache' => [ 'cache' => [
@ -172,10 +167,6 @@ return [
'password' => env('REDIS_PASSWORD'), 'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'), 'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'), 'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
], ],
], ],

View file

@ -35,16 +35,14 @@ return [
'root' => storage_path('app/private'), 'root' => storage_path('app/private'),
'serve' => true, 'serve' => true,
'throw' => false, 'throw' => false,
'report' => false,
], ],
'public' => [ 'public' => [
'driver' => 'local', 'driver' => 'local',
'root' => storage_path('app/public'), 'root' => storage_path('app/public'),
'url' => mb_rtrim(env('APP_URL'), '/').'/storage', 'url' => env('APP_URL').'/storage',
'visibility' => 'public', 'visibility' => 'public',
'throw' => false, 'throw' => false,
'report' => false,
], ],
's3' => [ 's3' => [
@ -57,7 +55,6 @@ return [
'endpoint' => env('AWS_ENDPOINT'), 'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false, 'throw' => false,
'report' => false,
], ],
], ],

View file

@ -1,159 +0,0 @@
<?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',
],
/*
|--------------------------------------------------------------------------
| 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.
|
*/
'views' => true,
/*
|--------------------------------------------------------------------------
| 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' => [
config('app.registration.enabled') && Features::registration(),
Features::resetPasswords(),
Features::emailVerification(),
// Features::updateProfileInformation(),
// Features::updatePasswords(),
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
// 'window' => 0,
]),
],
];

View file

@ -1,277 +0,0 @@
<?php
return [
/*
|---------------------------------------------------------------------------
| Component Locations
|---------------------------------------------------------------------------
|
| This value sets the root directories that'll be used to resolve view-based
| components like single and multi-file components. The make command will
| use the first directory in this array to add new component files to.
|
*/
'component_locations' => [
resource_path('views/components'),
resource_path('views/livewire'),
],
/*
|---------------------------------------------------------------------------
| Component Namespaces
|---------------------------------------------------------------------------
|
| This value sets default namespaces that will be used to resolve view-based
| components like single-file and multi-file components. These folders'll
| also be referenced when creating new components via the make command.
|
*/
'component_namespaces' => [
'layouts' => resource_path('views/layouts'),
'pages' => resource_path('views/pages'),
],
/*
|---------------------------------------------------------------------------
| Page Layout
|---------------------------------------------------------------------------
| The view that will be used as the layout when rendering a single component as
| an entire page via `Route::livewire('/post/create', 'pages::create-post')`.
| In this case, the content of pages::create-post will render into $slot.
|
*/
'component_layout' => 'layouts::app',
/*
|---------------------------------------------------------------------------
| Lazy Loading Placeholder
|---------------------------------------------------------------------------
| Livewire allows you to lazy load components that would otherwise slow down
| the initial page load. Every component can have a custom placeholder or
| you can define the default placeholder view for all components below.
|
*/
'component_placeholder' => null, // Example: 'placeholders::skeleton'
/*
|---------------------------------------------------------------------------
| Make Command
|---------------------------------------------------------------------------
| This value determines the default configuration for the artisan make command
| You can configure the component type (sfc, mfc, class) and whether to use
| the high-voltage () emoji as a prefix in the sfc|mfc component names.
|
*/
'make_command' => [
'type' => 'sfc', // Options: 'sfc', 'mfc', 'class'
'emoji' => false, // Options: true, false
],
/*
|---------------------------------------------------------------------------
| Class Namespace
|---------------------------------------------------------------------------
|
| This value sets the root class namespace for Livewire component classes in
| your application. This value will change where component auto-discovery
| finds components. It's also referenced by the file creation commands.
|
*/
'class_namespace' => 'App\\Livewire',
/*
|---------------------------------------------------------------------------
| Class Path
|---------------------------------------------------------------------------
|
| This value is used to specify the path where Livewire component class files
| are created when running creation commands like `artisan make:livewire`.
| This path is customizable to match your projects directory structure.
|
*/
'class_path' => app_path('Livewire'),
/*
|---------------------------------------------------------------------------
| View Path
|---------------------------------------------------------------------------
|
| This value is used to specify where Livewire component Blade templates are
| stored when running file creation commands like `artisan make:livewire`.
| It is also used if you choose to omit a component's render() method.
|
*/
'view_path' => resource_path('views/livewire'),
/*
|---------------------------------------------------------------------------
| Temporary File Uploads
|---------------------------------------------------------------------------
|
| Livewire handles file uploads by storing uploads in a temporary directory
| before the file is stored permanently. All file uploads are directed to
| a global endpoint for temporary storage. You may configure this below:
|
*/
'temporary_file_upload' => [
'disk' => env('LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK'), // Example: 'local', 's3' | Default: 'default'
'rules' => null, // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB)
'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp'
'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1'
'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs...
'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
'mov', 'avi', 'wmv', 'mp3', 'm4a',
'jpg', 'jpeg', 'mpga', 'webp', 'wma',
],
'max_upload_time' => 5, // Max duration (in minutes) before an upload is invalidated...
'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs...
],
/*
|---------------------------------------------------------------------------
| Render On Redirect
|---------------------------------------------------------------------------
|
| This value determines if Livewire will run a component's `render()` method
| after a redirect has been triggered using something like `redirect(...)`
| Setting this to true will render the view once more before redirecting
|
*/
'render_on_redirect' => false,
/*
|---------------------------------------------------------------------------
| Eloquent Model Binding
|---------------------------------------------------------------------------
|
| Previous versions of Livewire supported binding directly to eloquent model
| properties using wire:model by default. However, this behavior has been
| deemed too "magical" and has therefore been put under a feature flag.
|
*/
'legacy_model_binding' => false,
/*
|---------------------------------------------------------------------------
| Auto-inject Frontend Assets
|---------------------------------------------------------------------------
|
| By default, Livewire automatically injects its JavaScript and CSS into the
| <head> and <body> of pages containing Livewire components. By disabling
| this behavior, you need to use @livewireStyles and @livewireScripts.
|
*/
'inject_assets' => true,
/*
|---------------------------------------------------------------------------
| Navigate (SPA mode)
|---------------------------------------------------------------------------
|
| By adding `wire:navigate` to links in your Livewire application, Livewire
| will prevent the default link handling and instead request those pages
| via AJAX, creating an SPA-like effect. Configure this behavior here.
|
*/
'navigate' => [
'show_progress_bar' => true,
'progress_bar_color' => '#E05B45',
],
/*
|---------------------------------------------------------------------------
| HTML Morph Markers
|---------------------------------------------------------------------------
|
| Livewire intelligently "morphs" existing HTML into the newly rendered HTML
| after each update. To make this process more reliable, Livewire injects
| "markers" into the rendered Blade surrounding @if, @class & @foreach.
|
*/
'inject_morph_markers' => true,
/*
|---------------------------------------------------------------------------
| Smart Wire Keys
|---------------------------------------------------------------------------
|
| Livewire uses loops and keys used within loops to generate smart keys that
| are applied to nested components that don't have them. This makes using
| nested components more reliable by ensuring that they all have keys.
|
*/
'smart_wire_keys' => true,
/*
|---------------------------------------------------------------------------
| Pagination Theme
|---------------------------------------------------------------------------
|
| When enabling Livewire's pagination feature by using the `WithPagination`
| trait, Livewire will use Tailwind templates to render pagination views
| on the page. If you want Bootstrap CSS, you can specify: "bootstrap"
|
*/
'pagination_theme' => 'tailwind',
/*
|---------------------------------------------------------------------------
| Release Token
|---------------------------------------------------------------------------
|
| This token is stored client-side and sent along with each request to check
| a users session to see if a new release has invalidated it. If there is
| a mismatch it will throw an error and prompt for a browser refresh.
|
*/
'release_token' => 'a',
/*
|---------------------------------------------------------------------------
| CSP Safe
|---------------------------------------------------------------------------
|
| This config is used to determine if Livewire will use the CSP-safe version
| of Alpine in its bundle. This is useful for applications that are using
| strict Content Security Policy (CSP) to protect against XSS attacks.
|
*/
'csp_safe' => false,
/*
|---------------------------------------------------------------------------
| Payload Guards
|---------------------------------------------------------------------------
|
| These settings protect against malicious or oversized payloads that could
| cause denial of service. The default values should feel reasonable for
| most web applications. Each can be set to null to disable the limit.
|
*/
'payload' => [
'max_size' => 1024 * 1024, // 1MB - maximum request payload size in bytes
'max_nesting_depth' => 10, // Maximum depth of dot-notation property paths
'max_calls' => 50, // Maximum method calls per request
'max_components' => 20, // Maximum components per batch request
],
];

View file

@ -54,7 +54,7 @@ return [
'stack' => [ 'stack' => [
'driver' => 'stack', 'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')), 'channels' => explode(',', env('LOG_STACK', 'single')),
'ignore_exceptions' => false, 'ignore_exceptions' => false,
], ],
@ -98,10 +98,10 @@ return [
'driver' => 'monolog', 'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'), 'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class, 'handler' => StreamHandler::class,
'handler_with' => [ 'formatter' => env('LOG_STDOUT_FORMATTER'),
'with' => [
'stream' => 'php://stdout', 'stream' => 'php://stdout',
], ],
'formatter' => env('LOG_STDOUT_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class], 'processors' => [PsrLogMessageProcessor::class],
], ],
@ -109,10 +109,10 @@ return [
'driver' => 'monolog', 'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'), 'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class, 'handler' => StreamHandler::class,
'handler_with' => [ 'formatter' => env('LOG_STDERR_FORMATTER'),
'with' => [
'stream' => 'php://stderr', 'stream' => 'php://stderr',
], ],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class], 'processors' => [PsrLogMessageProcessor::class],
], ],

View file

@ -46,7 +46,7 @@ return [
'username' => env('MAIL_USERNAME'), 'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'), 'password' => env('MAIL_PASSWORD'),
'timeout' => null, 'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url(env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
], ],
'ses' => [ 'ses' => [
@ -85,7 +85,6 @@ return [
'smtp', 'smtp',
'log', 'log',
], ],
'retry_after' => 60,
], ],
'roundrobin' => [ 'roundrobin' => [
@ -94,7 +93,6 @@ return [
'ses', 'ses',
'postmark', 'postmark',
], ],
'retry_after' => 60,
], ],
], ],

View file

@ -24,8 +24,7 @@ return [
| used by your application. An example configuration is provided for | used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more. | each backend supported by Laravel. You're also free to add more.
| |
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis", | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null"
| "deferred", "background", "failover", "null"
| |
*/ */
@ -73,22 +72,6 @@ return [
'after_commit' => false, 'after_commit' => false,
], ],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
],
],
], ],
/* /*

View file

@ -15,11 +15,7 @@ return [
*/ */
'postmark' => [ 'postmark' => [
'key' => env('POSTMARK_API_KEY'), 'token' => env('POSTMARK_TOKEN'),
],
'resend' => [
'key' => env('RESEND_API_KEY'),
], ],
'ses' => [ 'ses' => [
@ -28,6 +24,10 @@ return [
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
], ],
'resend' => [
'key' => env('RESEND_KEY'),
],
'slack' => [ 'slack' => [
'notifications' => [ 'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),

View file

@ -13,8 +13,8 @@ return [
| incoming requests. Laravel supports a variety of storage options to | incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice. | persist session data. Database storage is a great default choice.
| |
| Supported: "file", "cookie", "database", "memcached", | Supported: "file", "cookie", "database", "apc",
| "redis", "dynamodb", "array" | "memcached", "redis", "dynamodb", "array"
| |
*/ */
@ -32,7 +32,7 @@ return [
| |
*/ */
'lifetime' => (int) env('SESSION_LIFETIME', 120), 'lifetime' => env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
@ -97,7 +97,7 @@ return [
| define the cache store which should be used to store the session data | define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores. | between requests. This must match one of your defined cache stores.
| |
| Affects: "dynamodb", "memcached", "redis" | Affects: "apc", "dynamodb", "memcached", "redis"
| |
*/ */
@ -129,7 +129,7 @@ return [
'cookie' => env( 'cookie' => env(
'SESSION_COOKIE', 'SESSION_COOKIE',
Str::slug((string) env('APP_NAME', 'laravel')).'-session' Str::slug(env('APP_NAME', 'laravel'), '_').'_session'
), ),
/* /*
@ -152,7 +152,7 @@ return [
| |
| This value determines the domain and subdomains the session cookie is | This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root | available to. By default, the cookie will be available to the root
| domain without subdomains. Typically, this shouldn't be changed. | domain and all subdomains. Typically, this shouldn't be changed.
| |
*/ */

View file

@ -29,9 +29,7 @@ class UserFactory extends Factory
'email_verified_at' => now(), 'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'), 'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10), 'remember_token' => Str::random(10),
'two_factor_secret' => null, 'assign_new_devices' => false,
'two_factor_recovery_codes' => null,
'two_factor_confirmed_at' => null,
]; ];
} }
@ -44,16 +42,4 @@ class UserFactory extends Factory
'email_verified_at' => null, 'email_verified_at' => null,
]); ]);
} }
/**
* Indicate that the model has two-factor authentication configured.
*/
public function withTwoFactor(): static
{
return $this->state(fn (array $attributes) => [
'two_factor_secret' => encrypt('secret'),
'two_factor_recovery_codes' => encrypt(json_encode(['recovery-code-1'])),
'two_factor_confirmed_at' => now(),
]);
}
} }

View file

@ -1,34 +0,0 @@
<?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

@ -8,7 +8,7 @@
x-show.transition.out.opacity.duration.1500ms="shown" x-show.transition.out.opacity.duration.1500ms="shown"
x-transition:leave.opacity.duration.1500ms x-transition:leave.opacity.duration.1500ms
style="display: none" style="display: none"
{{ $attributes->merge(['class' => 'text-sm']) }} {{ $attributes->merge(['class' => 'text-sm text-gray-600']) }}
> >
{{ $slot->isEmpty() ? __('Saved.') : $slot }} {{ $slot->isEmpty() ? __('Saved.') : $slot }}
</div> </div>

View file

@ -3,7 +3,7 @@
'description', 'description',
]) ])
<div class="flex w-full flex-col text-center"> <div class="flex w-full flex-col gap-2 text-center">
<flux:heading size="xl">{{ $title }}</flux:heading> <h1 class="text-xl font-medium dark:text-zinc-200">{{ $title }}</h1>
<flux:subheading>{{ $description }}</flux:subheading> <p class="text-center text-sm dark:text-zinc-400">{{ $description }}</p>
</div> </div>

View file

@ -1,39 +0,0 @@
<flux:dropdown position="bottom" align="start">
<flux:sidebar.profile
{{ $attributes->only('name') }}
:initials="auth()->user()->initials()"
icon:trailing="chevrons-up-down"
data-test="sidebar-menu-button"
/>
<flux:menu>
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
<flux:avatar
:name="auth()->user()->name"
:initials="auth()->user()->initials()"
/>
<div class="grid flex-1 text-start text-sm leading-tight">
<flux:heading class="truncate">{{ auth()->user()->name }}</flux:heading>
<flux:text class="truncate">{{ auth()->user()->email }}</flux:text>
</div>
</div>
<flux:menu.separator />
<flux:menu.radio.group>
<flux:menu.item :href="route('profile.edit')" icon="cog" wire:navigate>
{{ __('Settings') }}
</flux:menu.item>
<form method="POST" action="{{ route('logout') }}" class="w-full">
@csrf
<flux:menu.item
as="button"
type="submit"
icon="arrow-right-start-on-rectangle"
class="w-full cursor-pointer"
data-test="logout-button"
>
{{ __('Log Out') }}
</flux:menu.item>
</form>
</flux:menu.radio.group>
</flux:menu>
</flux:dropdown>

View file

@ -1,5 +1,5 @@
<x-layouts::app.header> <x-layouts.app.header>
<flux:main> <flux:main>
{{ $slot }} {{ $slot }}
</flux:main> </flux:main>
</x-layouts::app.header> </x-layouts.app.header>

View file

@ -0,0 +1,3 @@
<x-layouts.auth.card>
{{ $slot }}
</x-layouts.auth.card>

View file

@ -6,7 +6,7 @@
<body class="min-h-screen bg-neutral-100 antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900"> <body class="min-h-screen bg-neutral-100 antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
<div class="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10"> <div class="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div class="flex w-full max-w-md flex-col gap-6"> <div class="flex w-full max-w-md flex-col gap-6">
<a href="{{ route('home') }}" class="flex flex-col items-center gap-2 font-medium" wire:navigate> <a href="{{ route('home') }}" class="flex flex-col items-center gap-2 font-medium">
<span class="flex h-9 w-9 items-center justify-center rounded-md"> <span class="flex h-9 w-9 items-center justify-center rounded-md">
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" /> <x-app-logo-icon class="size-9 fill-current text-black dark:text-white" />
</span> </span>
@ -15,7 +15,7 @@
</a> </a>
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<div class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs"> <div class="styled-container">
<div class="px-10 py-8">{{ $slot }}</div> <div class="px-10 py-8">{{ $slot }}</div>
</div> </div>
</div> </div>

View file

@ -6,7 +6,7 @@
<body class="min-h-screen bg-white antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900"> <body class="min-h-screen bg-white antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
<div class="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10"> <div class="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div class="flex w-full max-w-sm flex-col gap-2"> <div class="flex w-full max-w-sm flex-col gap-2">
<a href="{{ route('home') }}" class="flex flex-col items-center gap-2 font-medium" wire:navigate> <a href="{{ route('home') }}" class="flex flex-col items-center gap-2 font-medium">
<span class="flex h-9 w-9 mb-1 items-center justify-center rounded-md"> <span class="flex h-9 w-9 mb-1 items-center justify-center rounded-md">
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" /> <x-app-logo-icon class="size-9 fill-current text-black dark:text-white" />
</span> </span>

View file

@ -5,11 +5,11 @@
</head> </head>
<body class="min-h-screen bg-white antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900"> <body class="min-h-screen bg-white antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
<div class="relative grid h-dvh flex-col items-center justify-center px-8 sm:px-0 lg:max-w-none lg:grid-cols-2 lg:px-0"> <div class="relative grid h-dvh flex-col items-center justify-center px-8 sm:px-0 lg:max-w-none lg:grid-cols-2 lg:px-0">
<div class="bg-muted relative hidden h-full flex-col p-10 text-white lg:flex dark:border-e dark:border-neutral-800"> <div class="bg-muted relative hidden h-full flex-col p-10 text-white lg:flex dark:border-r dark:border-neutral-800">
<div class="absolute inset-0 bg-neutral-900"></div> <div class="absolute inset-0 bg-neutral-900"></div>
<a href="{{ route('home') }}" class="relative z-20 flex items-center text-lg font-medium" wire:navigate> <a href="{{ route('home') }}" class="relative z-20 flex items-center text-lg font-medium">
<span class="flex h-10 w-10 items-center justify-center rounded-md"> <span class="flex h-10 w-10 items-center justify-center rounded-md">
<x-app-logo-icon class="me-2 h-7 fill-current text-white" /> <x-app-logo-icon class="mr-2 h-7 fill-current text-white" />
</span> </span>
{{ config('app.name', 'Laravel') }} {{ config('app.name', 'Laravel') }}
</a> </a>
@ -20,14 +20,14 @@
<div class="relative z-20 mt-auto"> <div class="relative z-20 mt-auto">
<blockquote class="space-y-2"> <blockquote class="space-y-2">
<flux:heading size="lg">&ldquo;{{ trim($message) }}&rdquo;</flux:heading> <p class="text-lg">&ldquo;{{ trim($message) }}&rdquo;</p>
<footer><flux:heading>{{ trim($author) }}</flux:heading></footer> <footer class="text-sm">{{ trim($author) }}</footer>
</blockquote> </blockquote>
</div> </div>
</div> </div>
<div class="w-full lg:p-8"> <div class="w-full lg:p-8">
<div class="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]"> <div class="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<a href="{{ route('home') }}" class="z-20 flex flex-col items-center gap-2 font-medium lg:hidden" wire:navigate> <a href="{{ route('home') }}" class="z-20 flex flex-col items-center gap-2 font-medium lg:hidden">
<span class="flex h-9 w-9 items-center justify-center rounded-md"> <span class="flex h-9 w-9 items-center justify-center rounded-md">
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" /> <x-app-logo-icon class="size-9 fill-current text-black dark:text-white" />
</span> </span>

View file

@ -0,0 +1,22 @@
<div class="flex items-start max-md:flex-col">
<div class="mr-10 w-full pb-4 md:w-[220px]">
<flux:navlist>
<flux:navlist.item href="{{ route('settings.preferences') }}" wire:navigate>Preferences</flux:navlist.item>
<flux:navlist.item href="{{ route('settings.profile') }}" wire:navigate>Profile</flux:navlist.item>
<flux:navlist.item href="{{ route('settings.password') }}" wire:navigate>Password</flux:navlist.item>
<flux:navlist.item href="{{ route('settings.appearance') }}" wire:navigate>Appearance</flux:navlist.item>
<flux:navlist.item href="{{ route('settings.support') }}" wire:navigate>Support</flux:navlist.item>
</flux:navlist>
</div>
<flux:separator class="md:hidden" />
<div class="flex-1 self-stretch max-md:pt-6">
<flux:heading>{{ $heading ?? '' }}</flux:heading>
<flux:subheading>{{ $subheading ?? '' }}</flux:subheading>
<div class="mt-5 w-full max-w-lg">
{{ $slot }}
</div>
</div>
</div>

View file

@ -4,7 +4,7 @@
'heading' => null, 'heading' => null,
]) ])
<?php if ($expandable && $heading) { ?> <?php if ($expandable && $heading): ?>
<ui-disclosure <ui-disclosure
{{ $attributes->class('group/disclosure') }} {{ $attributes->class('group/disclosure') }}
@ -15,7 +15,7 @@
type="button" type="button"
class="group/disclosure-button mb-[2px] flex h-10 w-full items-center rounded-lg text-zinc-500 hover:bg-zinc-800/5 hover:text-zinc-800 lg:h-8 dark:text-white/80 dark:hover:bg-white/[7%] dark:hover:text-white" class="group/disclosure-button mb-[2px] flex h-10 w-full items-center rounded-lg text-zinc-500 hover:bg-zinc-800/5 hover:text-zinc-800 lg:h-8 dark:text-white/80 dark:hover:bg-white/[7%] dark:hover:text-white"
> >
<div class="ps-3 pe-4"> <div class="pl-3 pr-4">
<flux:icon.chevron-down class="hidden size-3! group-data-open/disclosure-button:block" /> <flux:icon.chevron-down class="hidden size-3! group-data-open/disclosure-button:block" />
<flux:icon.chevron-right class="block size-3! group-data-open/disclosure-button:hidden" /> <flux:icon.chevron-right class="block size-3! group-data-open/disclosure-button:hidden" />
</div> </div>
@ -23,14 +23,14 @@
<span class="text-sm font-medium leading-none">{{ $heading }}</span> <span class="text-sm font-medium leading-none">{{ $heading }}</span>
</button> </button>
<div class="relative hidden space-y-[2px] ps-7 data-open:block" @if ($expanded === true) data-open @endif> <div class="relative hidden space-y-[2px] pl-7 data-open:block" @if ($expanded === true) data-open @endif>
<div class="absolute inset-y-[3px] start-0 ms-4 w-px bg-zinc-200 dark:bg-white/30"></div> <div class="absolute inset-y-[3px] left-0 ml-4 w-px bg-zinc-200 dark:bg-white/30"></div>
{{ $slot }} {{ $slot }}
</div> </div>
</ui-disclosure> </ui-disclosure>
<?php } elseif ($heading) { ?> <?php elseif ($heading): ?>
<div {{ $attributes->class('block space-y-[2px]') }}> <div {{ $attributes->class('block space-y-[2px]') }}>
<div class="px-1 py-2"> <div class="px-1 py-2">
@ -42,10 +42,10 @@
</div> </div>
</div> </div>
<?php } else { ?> <?php else: ?>
<div {{ $attributes->class('block space-y-[2px]') }}> <div {{ $attributes->class('block space-y-[2px]') }}>
{{ $slot }} {{ $slot }}
</div> </div>
<?php } ?> <?php endif; ?>

View file

@ -1,96 +0,0 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<head>
@include('partials.head')
</head>
<body class="min-h-screen bg-white dark:bg-zinc-800">
<flux:sidebar sticky collapsible="mobile" class="border-e border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
<flux:sidebar.header>
<x-app-logo :sidebar="true" href="{{ route('dashboard') }}" wire:navigate />
<flux:sidebar.collapse class="lg:hidden" />
</flux:sidebar.header>
<flux:sidebar.nav>
<flux:sidebar.group :heading="__('Platform')" class="grid">
<flux:sidebar.item icon="home" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>
{{ __('Dashboard') }}
</flux:sidebar.item>
</flux:sidebar.group>
</flux:sidebar.nav>
<flux:spacer />
<flux:sidebar.nav>
<flux:sidebar.item icon="folder-git-2" href="https://github.com/laravel/livewire-starter-kit" target="_blank">
{{ __('Repository') }}
</flux:sidebar.item>
<flux:sidebar.item icon="book-open-text" href="https://laravel.com/docs/starter-kits#livewire" target="_blank">
{{ __('Documentation') }}
</flux:sidebar.item>
</flux:sidebar.nav>
<x-desktop-user-menu class="hidden lg:block" :name="auth()->user()->name" />
</flux:sidebar>
<!-- Mobile User Menu -->
<flux:header class="lg:hidden">
<flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" />
<flux:spacer />
<flux:dropdown position="top" align="end">
<flux:profile
:initials="auth()->user()->initials()"
icon-trailing="chevron-down"
/>
<flux:menu>
<flux:menu.radio.group>
<div class="p-0 text-sm font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
<flux:avatar
:name="auth()->user()->name"
:initials="auth()->user()->initials()"
/>
<div class="grid flex-1 text-start text-sm leading-tight">
<flux:heading class="truncate">{{ auth()->user()->name }}</flux:heading>
<flux:text class="truncate">{{ auth()->user()->email }}</flux:text>
</div>
</div>
</div>
</flux:menu.radio.group>
<flux:menu.separator />
<flux:menu.radio.group>
<flux:menu.item :href="route('profile.edit')" icon="cog" wire:navigate>
{{ __('Settings') }}
</flux:menu.item>
</flux:menu.radio.group>
<flux:menu.separator />
<form method="POST" action="{{ route('logout') }}" class="w-full">
@csrf
<flux:menu.item
as="button"
type="submit"
icon="arrow-right-start-on-rectangle"
class="w-full cursor-pointer"
data-test="logout-button"
>
{{ __('Log Out') }}
</flux:menu.item>
</form>
</flux:menu>
</flux:dropdown>
</flux:header>
{{ $slot }}
@fluxScripts
</body>
</html>

View file

@ -1,3 +0,0 @@
<x-layouts::auth.simple :title="$title ?? null">
{{ $slot }}
</x-layouts::auth.simple>

View file

@ -0,0 +1,61 @@
<?php
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
new #[Layout('components.layouts.auth')] class extends Component {
public string $password = '';
/**
* Confirm the current user's password.
*/
public function confirmPassword(): void
{
$this->validate([
'password' => ['required', 'string'],
]);
if (! Auth::guard('web')->validate([
'email' => Auth::user()->email,
'password' => $this->password,
])) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
session(['auth.password_confirmed_at' => time()]);
$this->redirectIntended(default: route('dashboard', absolute: false), navigate: true);
}
}; ?>
<div class="flex flex-col gap-6">
<x-auth-header
title="Confirm password"
description="This is a secure area of the application. Please confirm your password before continuing."
/>
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form wire:submit="confirmPassword" class="flex flex-col gap-6">
<!-- Password -->
<div class="grid gap-2">
<flux:input
wire:model="password"
id="password"
label="{{ __('Password') }}"
type="password"
name="password"
required
autocomplete="new-password"
placeholder="Password"
/>
</div>
<flux:button variant="primary" type="submit" class="w-full">{{ __('Confirm') }}</flux:button>
</form>
</div>

View file

@ -0,0 +1,44 @@
<?php
use Illuminate\Support\Facades\Password;
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
new #[Layout('components.layouts.auth')] class extends Component {
public string $email = '';
/**
* Send a password reset link to the provided email address.
*/
public function sendPasswordResetLink(): void
{
$this->validate([
'email' => ['required', 'string', 'email'],
]);
Password::sendResetLink($this->only('email'));
session()->flash('status', __('A reset link will be sent if the account exists.'));
}
}; ?>
<div class="flex flex-col gap-6">
<x-auth-header title="Forgot password" description="Enter your email to receive a password reset link" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form wire:submit="sendPasswordResetLink" class="flex flex-col gap-6">
<!-- Email Address -->
<div class="grid gap-2">
<flux:input wire:model="email" label="{{ __('Email Address') }}" type="email" name="email" required autofocus placeholder="email@example.com" />
</div>
<flux:button variant="primary" type="submit" class="w-full">{{ __('Email password reset link') }}</flux:button>
</form>
<div class="space-x-1 text-center text-sm text-zinc-400">
Or, return to
<x-text-link href="{{ route('login') }}">log in</x-text-link>
</div>
</div>

View file

@ -0,0 +1,152 @@
<?php
use Illuminate\Auth\Events\Lockout;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Volt\Component;
new #[Layout('components.layouts.auth')] class extends Component {
#[Validate('required|string|email')]
public string $email = '';
#[Validate('required|string')]
public string $password = '';
public bool $remember = true;
/**
* Handle an incoming authentication request.
*/
public function login(): void
{
$this->validate();
$this->ensureIsNotRateLimited();
if (!Auth::attempt(['email' => $this->email, 'password' => $this->password], $this->remember)) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => __('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
Session::regenerate();
$this->redirectIntended(default: route('dashboard', absolute: false), navigate: true);
}
/**
* Ensure the authentication request is not rate limited.
*/
protected function ensureIsNotRateLimited(): void
{
if (!RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout(request()));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => __('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the authentication rate limiting throttle key.
*/
protected function throttleKey(): string
{
return Str::transliterate(Str::lower($this->email) . '|' . request()->ip());
}
public function mount(): void
{
if (app()->isLocal()) {
$this->email = 'admin@example.com';
$this->password = 'admin@example.com';
}
}
}; ?>
<div class="flex flex-col gap-6">
<x-auth-header title="Log in to your account" description="Enter your email and password below to log in"/>
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')"/>
<form wire:submit="login" class="flex flex-col gap-6">
<!-- Email Address -->
<flux:input wire:model="email" label="{{ __('Email address') }}" type="email" name="email" required autofocus
autocomplete="email" placeholder="admin@example.com"/>
<!-- Password -->
<div class="relative">
<flux:input
wire:model="password"
label="{{ __('Password') }}"
type="password"
name="password"
required
autocomplete="current-password"
placeholder="Password"
/>
@if (Route::has('password.request'))
<x-text-link class="absolute right-0 top-0" href="{{ route('password.request') }}">
{{ __('Forgot your password?') }}
</x-text-link>
@endif
</div>
<!-- Remember Me -->
<flux:checkbox wire:model="remember" label="{{ __('Remember me') }}"/>
<div class="flex items-center justify-end">
<flux:button variant="primary" type="submit" class="w-full">{{ __('Log in') }}</flux:button>
</div>
</form>
@if (config('services.oidc.enabled'))
<div class="relative">
<div class="absolute inset-0 flex items-center">
<span class="w-full border-t border-zinc-300 dark:border-zinc-600"></span>
</div>
<div class="relative flex justify-center text-sm">
<span class="bg-white dark:bg-zinc-900 px-2 text-zinc-500 dark:text-zinc-400">
{{ __('Or') }}
</span>
</div>
</div>
<div class="flex items-center justify-end">
<flux:button
variant="outline"
type="button"
class="w-full"
href="{{ route('auth.oidc.redirect') }}"
>
{{ __('Continue with OIDC') }}
</flux:button>
</div>
@endif
@if (Route::has('register'))
<div class="space-x-1 text-center text-sm text-zinc-600 dark:text-zinc-400">
Don't have an account?
<x-text-link href="{{ route('register') }}">Sign up</x-text-link>
</div>
@endif
</div>

View file

@ -0,0 +1,94 @@
<?php
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
new #[Layout('components.layouts.auth')] class extends Component {
public string $name = '';
public string $email = '';
public string $password = '';
public string $password_confirmation = '';
/**
* Handle an incoming registration request.
*/
public function register(): void
{
$validated = $this->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:' . User::class],
'password' => ['required', 'string', 'confirmed', Rules\Password::defaults()],
]);
$validated['password'] = Hash::make($validated['password']);
event(new Registered(($user = User::create($validated))));
Auth::login($user);
$this->redirect(route('dashboard', absolute: false), navigate: true);
}
}; ?>
<div class="flex flex-col gap-6">
<x-auth-header title="Create an account" description="Enter your details below to create your account" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form wire:submit="register" class="flex flex-col gap-6">
<!-- Name -->
<div class="grid gap-2">
<flux:input wire:model="name" id="name" label="{{ __('Name') }}" type="text" name="name" required autofocus autocomplete="name" placeholder="Full name" />
</div>
<!-- Email Address -->
<div class="grid gap-2">
<flux:input wire:model="email" id="email" label="{{ __('Email address') }}" type="email" name="email" required autocomplete="email" placeholder="email@example.com" />
</div>
<!-- Password -->
<div class="grid gap-2">
<flux:input
wire:model="password"
id="password"
label="{{ __('Password') }}"
type="password"
name="password"
required
autocomplete="new-password"
placeholder="Password"
/>
</div>
<!-- Confirm Password -->
<div class="grid gap-2">
<flux:input
wire:model="password_confirmation"
id="password_confirmation"
label="{{ __('Confirm password') }}"
type="password"
name="password_confirmation"
required
autocomplete="new-password"
placeholder="Confirm password"
/>
</div>
<div class="flex items-center justify-end">
<flux:button type="submit" variant="primary" class="w-full">
{{ __('Create account') }}
</flux:button>
</div>
</form>
<div class="space-x-1 text-center text-sm text-zinc-600 dark:text-zinc-400">
Already have an account?
<x-text-link href="{{ route('login') }}">Log in</x-text-link>
</div>
</div>

View file

@ -0,0 +1,117 @@
<?php
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Volt\Component;
new #[Layout('components.layouts.auth')] class extends Component {
#[Locked]
public string $token = '';
public string $email = '';
public string $password = '';
public string $password_confirmation = '';
/**
* Mount the component.
*/
public function mount(string $token): void
{
$this->token = $token;
$this->email = request()->string('email');
}
/**
* Reset the password for the given user.
*/
public function resetPassword(): void
{
$this->validate([
'token' => ['required'],
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$this->only('email', 'password', 'password_confirmation', 'token'),
function ($user) {
$user->forceFill([
'password' => Hash::make($this->password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
if ($status != Password::PasswordReset) {
$this->addError('email', __($status));
return;
}
Session::flash('status', __($status));
$this->redirectRoute('login', navigate: true);
}
}; ?>
<div class="flex flex-col gap-6">
<x-auth-header title="Reset password" description="Please enter your new password below" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form wire:submit="resetPassword" class="flex flex-col gap-6">
<!-- Email Address -->
<div class="grid gap-2">
<flux:input wire:model="email" id="email" label="{{ __('Email') }}" type="email" name="email" required autocomplete="email" />
</div>
<!-- Password -->
<div class="grid gap-2">
<flux:input
wire:model="password"
id="password"
label="{{ __('Password') }}"
type="password"
name="password"
required
autocomplete="new-password"
placeholder="Password"
/>
</div>
<!-- Confirm Password -->
<div class="grid gap-2">
<flux:input
wire:model="password_confirmation"
id="password_confirmation"
label="{{ __('Confirm password') }}"
type="password"
name="password_confirmation"
required
autocomplete="new-password"
placeholder="Confirm password"
/>
</div>
<div class="flex items-center justify-end">
<flux:button type="submit" variant="primary" class="w-full">
{{ __('Reset password') }}
</flux:button>
</div>
</form>
</div>

View file

@ -0,0 +1,61 @@
<?php
use App\Livewire\Actions\Logout;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
new #[Layout('components.layouts.auth')] class extends Component {
/**
* Send an email verification notification to the user.
*/
public function sendVerification(): void
{
if (Auth::user()->hasVerifiedEmail()) {
$this->redirectIntended(default: route('dashboard', absolute: false), navigate: true);
return;
}
Auth::user()->sendEmailVerificationNotification();
Session::flash('status', 'verification-link-sent');
}
/**
* Log the current user out of the application.
*/
public function logout(Logout $logout): void
{
$logout();
$this->redirect('/', navigate: true);
}
}; ?>
<div class="mt-4 flex flex-col gap-6">
<div class="text-center text-sm text-gray-600">
{{ __('Please verify your email address by clicking on the link we just emailed to you.') }}
</div>
@if (session('status') == 'verification-link-sent')
<div class="font-medium text-center text-sm text-green-600">
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
</div>
@endif
<div class="flex flex-col items-center justify-between space-y-3">
<flux:button wire:click="sendVerification" variant="primary" class="w-full">
{{ __('Resend verification email') }}
</flux:button>
<button
wire:click="logout"
type="submit"
class="rounded-md text-sm text-gray-600 underline hover:text-gray-900 focus:outline-hidden focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
{{ __('Log out') }}
</button>
</div>
</div>

View file

@ -6,7 +6,7 @@ use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Livewire\Attributes\Lazy; use Livewire\Attributes\Lazy;
use Livewire\Component; use Livewire\Volt\Component;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
new new

View file

@ -5,7 +5,7 @@ use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Livewire\Attributes\Lazy; use Livewire\Attributes\Lazy;
use Livewire\Component; use Livewire\Volt\Component;
new new
#[Lazy] #[Lazy]
@ -122,7 +122,7 @@ class extends Component
public function loadMore(): void public function loadMore(): void
{ {
++$this->page; $this->page++;
$term = mb_trim($this->search); $term = mb_trim($this->search);
if ($term === '' || mb_strlen($term) < 2) { if ($term === '' || mb_strlen($term) < 2) {

View file

@ -1,23 +1,17 @@
<?php <?php
use Livewire\Volt\Component;
use Livewire\Attributes\Modelable; use Livewire\Attributes\Modelable;
use Livewire\Component;
new class extends Component new class extends Component
{ {
#[Modelable] #[Modelable]
public $model = ''; public $model = '';
public $language = 'html'; public $language = 'html';
public $theme = 'auto'; public $theme = 'auto';
public $readonly = false; public $readonly = false;
public $placeholder = ''; public $placeholder = '';
public $height = '200px'; public $height = '200px';
public $id = ''; public $id = '';
public function mount($language = 'html', $theme = 'auto', $readonly = false, $placeholder = '', $height = '200px', $id = '') public function mount($language = 'html', $theme = 'auto', $readonly = false, $placeholder = '', $height = '200px', $id = '')
@ -30,6 +24,7 @@ new class extends Component
$this->id = $id; $this->id = $id;
} }
public function toJSON() public function toJSON()
{ {
return json_encode([ return json_encode([

View file

@ -1,9 +1,8 @@
<?php <?php
use Livewire\Component; use Livewire\Volt\Component;
new class extends Component new class extends Component {
{
public function mount() public function mount()
{ {
return view('livewire.device-dashboard', ['devices' => auth()->user()->devices()->paginate(10)]); return view('livewire.device-dashboard', ['devices' => auth()->user()->devices()->paginate(10)]);

View file

@ -2,7 +2,7 @@
use App\Models\DeviceModel; use App\Models\DeviceModel;
use App\Models\DevicePalette; use App\Models\DevicePalette;
use Livewire\Component; use Livewire\Volt\Component;
new class extends Component new class extends Component
{ {

View file

@ -1,7 +1,7 @@
<?php <?php
use App\Models\DevicePalette; use App\Models\DevicePalette;
use Livewire\Component; use Livewire\Volt\Component;
new class extends Component new class extends Component
{ {

View file

@ -5,52 +5,35 @@ use App\Models\DeviceModel;
use App\Models\Firmware; use App\Models\Firmware;
use App\Models\Playlist; use App\Models\Playlist;
use App\Models\PlaylistItem; use App\Models\PlaylistItem;
use Livewire\Component; use Livewire\Volt\Component;
new class extends Component {
new class extends Component
{
public $device; public $device;
public $name; public $name;
public $api_key; public $api_key;
public $friendly_id; public $friendly_id;
public $mac_address; public $mac_address;
public $default_refresh_interval; public $default_refresh_interval;
public $width; public $width;
public $height; public $height;
public $rotate; public $rotate;
public $image_format; public $image_format;
public $device_model_id; public $device_model_id;
// Sleep mode and special function // Sleep mode and special function
public $sleep_mode_enabled = false; public $sleep_mode_enabled = false;
public $sleep_mode_from; public $sleep_mode_from;
public $sleep_mode_to; public $sleep_mode_to;
public $special_function; public $special_function;
// Playlist properties // Playlist properties
public $playlists; public $playlists;
public $playlist_name; public $playlist_name;
public $selected_weekdays = null; public $selected_weekdays = null;
public $active_from; public $active_from;
public $active_until; public $active_until;
public $refresh_time = null; public $refresh_time = null;
// Device model properties // Device model properties
@ -58,17 +41,15 @@ new class extends Component
// Firmware properties // Firmware properties
public $firmwares; public $firmwares;
public $selected_firmware_id; public $selected_firmware_id;
public $download_firmware; public $download_firmware;
public function mount(App\Models\Device $device) public function mount(\App\Models\Device $device)
{ {
abort_unless(auth()->user()->devices->contains($device), 403); abort_unless(auth()->user()->devices->contains($device), 403);
$current_image_uuid = $device->current_screen_image; $current_image_uuid = $device->current_screen_image;
$current_image_path = 'images/generated/'.$current_image_uuid.'.png'; $current_image_path = 'images/generated/' . $current_image_uuid . '.png';
$this->device = $device; $this->device = $device;
$this->name = $device->name; $this->name = $device->name;
@ -84,11 +65,10 @@ new class extends Component
$this->deviceModels = DeviceModel::orderBy('label')->get()->sortBy(function ($deviceModel) { $this->deviceModels = DeviceModel::orderBy('label')->get()->sortBy(function ($deviceModel) {
// Put TRMNL models at the top, then sort alphabetically within each group // Put TRMNL models at the top, then sort alphabetically within each group
$isTrmnl = str_starts_with($deviceModel->label, 'TRMNL'); $isTrmnl = str_starts_with($deviceModel->label, 'TRMNL');
return $isTrmnl ? '0' . $deviceModel->label : '1' . $deviceModel->label;
return $isTrmnl ? '0'.$deviceModel->label : '1'.$deviceModel->label;
}); });
$this->playlists = $device->playlists()->with('items.plugin')->orderBy('created_at')->get(); $this->playlists = $device->playlists()->with('items.plugin')->orderBy('created_at')->get();
$this->firmwares = Firmware::orderBy('latest', 'desc')->orderBy('created_at', 'desc')->get(); $this->firmwares = \App\Models\Firmware::orderBy('latest', 'desc')->orderBy('created_at', 'desc')->get();
$this->selected_firmware_id = $this->firmwares->where('latest', true)->first()?->id; $this->selected_firmware_id = $this->firmwares->where('latest', true)->first()?->id;
$this->sleep_mode_enabled = $device->sleep_mode_enabled ?? false; $this->sleep_mode_enabled = $device->sleep_mode_enabled ?? false;
$this->sleep_mode_from = optional($device->sleep_mode_from)->format('H:i'); $this->sleep_mode_from = optional($device->sleep_mode_from)->format('H:i');
@ -100,7 +80,7 @@ new class extends Component
]); ]);
} }
public function deleteDevice(App\Models\Device $device) public function deleteDevice(\App\Models\Device $device)
{ {
abort_unless(auth()->user()->devices->contains($device), 403); abort_unless(auth()->user()->devices->contains($device), 403);
$device->delete(); $device->delete();
@ -113,7 +93,6 @@ new class extends Component
// Convert empty string to null for custom selection // Convert empty string to null for custom selection
if (empty($this->device_model_id)) { if (empty($this->device_model_id)) {
$this->device_model_id = null; $this->device_model_id = null;
return; return;
} }
@ -183,7 +162,7 @@ new class extends Component
$this->refresh_time = null; $this->refresh_time = null;
} }
if (empty($this->selected_weekdays)) { if (empty($this->selected_weekdays)){
$this->selected_weekdays = null; $this->selected_weekdays = null;
} }
@ -203,7 +182,7 @@ new class extends Component
public function togglePlaylistActive(Playlist $playlist) public function togglePlaylistActive(Playlist $playlist)
{ {
$playlist->update(['is_active' => ! $playlist->is_active]); $playlist->update(['is_active' => !$playlist->is_active]);
$this->playlists = $this->device->playlists()->with('items.plugin')->orderBy('created_at')->get(); $this->playlists = $this->device->playlists()->with('items.plugin')->orderBy('created_at')->get();
} }
@ -239,7 +218,7 @@ new class extends Component
public function togglePlaylistItemActive(PlaylistItem $item) public function togglePlaylistItemActive(PlaylistItem $item)
{ {
$item->update(['is_active' => ! $item->is_active]); $item->update(['is_active' => !$item->is_active]);
$this->playlists = $this->device->playlists()->with('items.plugin')->orderBy('created_at')->get(); $this->playlists = $this->device->playlists()->with('items.plugin')->orderBy('created_at')->get();
} }
@ -248,7 +227,7 @@ new class extends Component
abort_unless(auth()->user()->devices->contains($playlist->device), 403); abort_unless(auth()->user()->devices->contains($playlist->device), 403);
$playlist->delete(); $playlist->delete();
$this->playlists = $this->device->playlists()->with('items.plugin')->orderBy('created_at')->get(); $this->playlists = $this->device->playlists()->with('items.plugin')->orderBy('created_at')->get();
Flux::modal('delete-playlist-'.$playlist->id)->close(); Flux::modal('delete-playlist-' . $playlist->id)->close();
} }
public function deletePlaylistItem(PlaylistItem $item) public function deletePlaylistItem(PlaylistItem $item)
@ -256,7 +235,7 @@ new class extends Component
abort_unless(auth()->user()->devices->contains($item->playlist->device), 403); abort_unless(auth()->user()->devices->contains($item->playlist->device), 403);
$item->delete(); $item->delete();
$this->playlists = $this->device->playlists()->with('items.plugin')->orderBy('created_at')->get(); $this->playlists = $this->device->playlists()->with('items.plugin')->orderBy('created_at')->get();
Flux::modal('delete-playlist-item-'.$item->id)->close(); Flux::modal('delete-playlist-item-' . $item->id)->close();
} }
public function editPlaylist(Playlist $playlist) public function editPlaylist(Playlist $playlist)
@ -279,7 +258,7 @@ new class extends Component
$this->refresh_time = null; $this->refresh_time = null;
} }
if (empty($this->selected_weekdays)) { if (empty($this->selected_weekdays)){
$this->selected_weekdays = null; $this->selected_weekdays = null;
} }
@ -293,7 +272,7 @@ new class extends Component
$this->playlists = $this->device->playlists()->with('items.plugin')->orderBy('created_at')->get(); $this->playlists = $this->device->playlists()->with('items.plugin')->orderBy('created_at')->get();
$this->reset(['playlist_name', 'selected_weekdays', 'active_from', 'active_until', 'refresh_time']); $this->reset(['playlist_name', 'selected_weekdays', 'active_from', 'active_until', 'refresh_time']);
Flux::modal('edit-playlist-'.$playlist->id)->close(); Flux::modal('edit-playlist-' . $playlist->id)->close();
} }
public function preparePlaylistEdit(Playlist $playlist) public function preparePlaylistEdit(Playlist $playlist)
@ -313,6 +292,7 @@ new class extends Component
'selected_firmware_id' => 'required|exists:firmware,id', 'selected_firmware_id' => 'required|exists:firmware,id',
]); ]);
if ($this->download_firmware) { if ($this->download_firmware) {
FirmwareDownloadJob::dispatchSync(Firmware::find($this->selected_firmware_id)); FirmwareDownloadJob::dispatchSync(Firmware::find($this->selected_firmware_id));
} }

View file

@ -1,12 +1,11 @@
<?php <?php
use App\Models\Device; use App\Models\Device;
use Livewire\Component; use App\Models\DeviceLog;
use Livewire\Volt\Component;
new class extends Component new class extends Component {
{
public Device $device; public Device $device;
public $logs; public $logs;
public function mount(Device $device) public function mount(Device $device)

View file

@ -2,10 +2,10 @@
use App\Models\Device; use App\Models\Device;
use App\Models\DeviceModel; use App\Models\DeviceModel;
use Livewire\Component; use Livewire\Volt\Component;
new class extends Component {
new class extends Component
{
public $devices; public $devices;
public $showDeviceForm = false; public $showDeviceForm = false;
@ -23,9 +23,7 @@ new class extends Component
public $is_mirror = false; public $is_mirror = false;
public $mirror_device_id = null; public $mirror_device_id = null;
public $device_model_id = null; public $device_model_id = null;
public $deviceModels; public $deviceModels;
public ?int $pause_duration; public ?int $pause_duration;
@ -44,10 +42,8 @@ new class extends Component
$this->deviceModels = DeviceModel::orderBy('label')->get()->sortBy(function ($deviceModel) { $this->deviceModels = DeviceModel::orderBy('label')->get()->sortBy(function ($deviceModel) {
// Put TRMNL models at the top, then sort alphabetically within each group // Put TRMNL models at the top, then sort alphabetically within each group
$isTrmnl = str_starts_with($deviceModel->label, 'TRMNL'); $isTrmnl = str_starts_with($deviceModel->label, 'TRMNL');
return $isTrmnl ? '0' . $deviceModel->label : '1' . $deviceModel->label;
return $isTrmnl ? '0'.$deviceModel->label : '1'.$deviceModel->label;
}); });
return view('livewire.devices.manage'); return view('livewire.devices.manage');
} }
@ -85,7 +81,7 @@ new class extends Component
]); ]);
$this->reset(); $this->reset();
Flux::modal('create-device')->close(); \Flux::modal('create-device')->close();
$this->devices = auth()->user()->devices; $this->devices = auth()->user()->devices;
session()->flash('message', 'Device created successfully.'); session()->flash('message', 'Device created successfully.');
@ -95,7 +91,7 @@ new class extends Component
{ {
abort_unless(auth()->user()->devices->contains($device), 403); abort_unless(auth()->user()->devices->contains($device), 403);
$device->update([ $device->update([
'proxy_cloud' => ! $device->proxy_cloud, 'proxy_cloud' => !$device->proxy_cloud,
]); ]);
// if ($device->proxy_cloud) { // if ($device->proxy_cloud) {
@ -112,9 +108,9 @@ new class extends Component
$pauseUntil = now()->addMinutes($this->pause_duration); $pauseUntil = now()->addMinutes($this->pause_duration);
$device->update(['pause_until' => $pauseUntil]); $device->update(['pause_until' => $pauseUntil]);
$this->reset('pause_duration'); $this->reset('pause_duration');
Flux::modal('pause-device-'.$deviceId)->close(); \Flux::modal('pause-device-' . $deviceId)->close();
$this->devices = auth()->user()->devices; $this->devices = auth()->user()->devices;
session()->flash('message', 'Device paused until '.$pauseUntil->format('H:i')); session()->flash('message', 'Device paused until ' . $pauseUntil->format('H:i'));
} }
} }

View file

@ -1,37 +1,31 @@
<?php <?php
use App\Models\Device;
use App\Models\Playlist; use App\Models\Playlist;
use App\Models\PlaylistItem; use App\Models\PlaylistItem;
use Livewire\Component; use Livewire\Volt\Component;
new class extends Component new class extends Component {
{
public $devices; public $devices;
public $playlists; public $playlists;
// Playlist form properties // Playlist form properties
public $playlist_name; public $playlist_name;
public $selected_weekdays = null; public $selected_weekdays = null;
public $active_from; public $active_from;
public $active_until; public $active_until;
public $refresh_time = null; public $refresh_time = null;
public function mount() public function mount()
{ {
$this->devices = auth()->user()->devices()->with(['playlists.items.plugin'])->get(); $this->devices = auth()->user()->devices()->with(['playlists.items.plugin'])->get();
return view('livewire.playlists.index'); return view('livewire.playlists.index');
} }
public function togglePlaylistActive(Playlist $playlist) public function togglePlaylistActive(Playlist $playlist)
{ {
abort_unless(auth()->user()->devices->contains($playlist->device), 403); abort_unless(auth()->user()->devices->contains($playlist->device), 403);
$playlist->update(['is_active' => ! $playlist->is_active]); $playlist->update(['is_active' => !$playlist->is_active]);
$this->devices = auth()->user()->devices()->with(['playlists.items.plugin'])->get(); $this->devices = auth()->user()->devices()->with(['playlists.items.plugin'])->get();
} }
@ -70,7 +64,7 @@ new class extends Component
public function togglePlaylistItemActive(PlaylistItem $item) public function togglePlaylistItemActive(PlaylistItem $item)
{ {
abort_unless(auth()->user()->devices->contains($item->playlist->device), 403); abort_unless(auth()->user()->devices->contains($item->playlist->device), 403);
$item->update(['is_active' => ! $item->is_active]); $item->update(['is_active' => !$item->is_active]);
$this->devices = auth()->user()->devices()->with(['playlists.items.plugin'])->get(); $this->devices = auth()->user()->devices()->with(['playlists.items.plugin'])->get();
} }
@ -79,7 +73,7 @@ new class extends Component
abort_unless(auth()->user()->devices->contains($playlist->device), 403); abort_unless(auth()->user()->devices->contains($playlist->device), 403);
$playlist->delete(); $playlist->delete();
$this->devices = auth()->user()->devices()->with(['playlists.items.plugin'])->get(); $this->devices = auth()->user()->devices()->with(['playlists.items.plugin'])->get();
Flux::modal('delete-playlist-'.$playlist->id)->close(); Flux::modal('delete-playlist-' . $playlist->id)->close();
} }
public function deletePlaylistItem(PlaylistItem $item) public function deletePlaylistItem(PlaylistItem $item)
@ -87,7 +81,7 @@ new class extends Component
abort_unless(auth()->user()->devices->contains($item->playlist->device), 403); abort_unless(auth()->user()->devices->contains($item->playlist->device), 403);
$item->delete(); $item->delete();
$this->devices = auth()->user()->devices()->with(['playlists.items.plugin'])->get(); $this->devices = auth()->user()->devices()->with(['playlists.items.plugin'])->get();
Flux::modal('delete-playlist-item-'.$item->id)->close(); Flux::modal('delete-playlist-item-' . $item->id)->close();
} }
public function editPlaylist(Playlist $playlist) public function editPlaylist(Playlist $playlist)
@ -112,7 +106,7 @@ new class extends Component
$this->refresh_time = null; $this->refresh_time = null;
} }
if (empty($this->selected_weekdays)) { if (empty($this->selected_weekdays)){
$this->selected_weekdays = null; $this->selected_weekdays = null;
} }
@ -126,7 +120,7 @@ new class extends Component
$this->devices = auth()->user()->devices()->with(['playlists.items.plugin'])->get(); $this->devices = auth()->user()->devices()->with(['playlists.items.plugin'])->get();
$this->reset(['playlist_name', 'selected_weekdays', 'active_from', 'active_until', 'refresh_time']); $this->reset(['playlist_name', 'selected_weekdays', 'active_from', 'active_until', 'refresh_time']);
Flux::modal('edit-playlist-'.$playlist->id)->close(); Flux::modal('edit-playlist-' . $playlist->id)->close();
} }
public function preparePlaylistEdit(Playlist $playlist) public function preparePlaylistEdit(Playlist $playlist)

View file

@ -1,13 +1,12 @@
<?php <?php
use Livewire\Component; use Livewire\Volt\Component;
use Illuminate\Support\Collection;
new class extends Component
{ new class extends Component {
public $token; public $token;
public $devices; public $devices;
public $selected_device; public $selected_device;
public function mount(): void public function mount(): void

View file

@ -1,30 +1,26 @@
<?php <?php
use App\Models\Plugin; use App\Models\Plugin;
use Livewire\Volt\Component;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Livewire\Component;
/* /*
* This component contains the configuation modal * This component contains the configuation modal
*/ */
new class extends Component new class extends Component {
{
public Plugin $plugin; public Plugin $plugin;
public array $configuration_template = []; public array $configuration_template = [];
public array $configuration = []; // holds config data public array $configuration = []; // holds config data
public array $multiValues = []; // UI boxes for multi_string public array $multiValues = []; // UI boxes for multi_string
public array $xhrSelectOptions = []; public array $xhrSelectOptions = [];
public array $searchQueries = []; public array $searchQueries = [];
// ------------------------------------This section contains one-off functions for the form------------------------------------------------ // ------------------------------------This section contains one-off functions for the form------------------------------------------------
public function mount(): void public function mount(): void
{ {
$this->loadData(); $this -> loadData();
} }
public function loadData(): void public function loadData(): void
@ -42,7 +38,7 @@ new class extends Component
$fieldKey = $field['keyname']; $fieldKey = $field['keyname'];
$rawValue = $this->configuration[$fieldKey] ?? ($field['default'] ?? ''); $rawValue = $this->configuration[$fieldKey] ?? ($field['default'] ?? '');
$currentValue = is_array($rawValue) ? '' : (string) $rawValue; $currentValue = is_array($rawValue) ? '' : (string)$rawValue;
$this->multiValues[$fieldKey] = $currentValue !== '' $this->multiValues[$fieldKey] = $currentValue !== ''
? array_values(array_filter(explode(',', $currentValue))) ? array_values(array_filter(explode(',', $currentValue)))
@ -55,11 +51,10 @@ new class extends Component
* Triggered by @close on the modal to discard any typed but unsaved changes * Triggered by @close on the modal to discard any typed but unsaved changes
*/ */
public int $resetIndex = 0; // Add this property public int $resetIndex = 0; // Add this property
public function resetForm(): void public function resetForm(): void
{ {
$this->loadData(); $this->loadData();
++$this->resetIndex; // Increment to force DOM refresh $this->resetIndex++; // Increment to force DOM refresh
} }
public function saveConfiguration() public function saveConfiguration()
@ -136,7 +131,7 @@ new class extends Component
if ($query !== null) { if ($query !== null) {
$requestData = [ $requestData = [
'function' => $fieldKey, 'function' => $fieldKey,
'query' => $query, 'query' => $query
]; ];
} }
@ -149,7 +144,7 @@ new class extends Component
} else { } else {
$this->xhrSelectOptions[$fieldKey] = []; $this->xhrSelectOptions[$fieldKey] = [];
} }
} catch (Exception $e) { } catch (\Exception $e) {
$this->xhrSelectOptions[$fieldKey] = []; $this->xhrSelectOptions[$fieldKey] = [];
} }
} }
@ -157,11 +152,11 @@ new class extends Component
public function searchXhrSelect(string $fieldKey, string $endpoint): void public function searchXhrSelect(string $fieldKey, string $endpoint): void
{ {
$query = $this->searchQueries[$fieldKey] ?? ''; $query = $this->searchQueries[$fieldKey] ?? '';
if (! empty($query)) { if (!empty($query)) {
$this->loadXhrSelectOptions($fieldKey, $endpoint, $query); $this->loadXhrSelectOptions($fieldKey, $endpoint, $query);
} }
} }
}; ?> };?>
<flux:modal name="configuration-modal" @close="resetForm" class="md:w-96"> <flux:modal name="configuration-modal" @close="resetForm" class="md:w-96">
<div wire:key="config-form-{{ $resetIndex }}" class="space-y-6"> <div wire:key="config-form-{{ $resetIndex }}" class="space-y-6">

View file

@ -1,24 +1,16 @@
<?php <?php
use App\Models\Plugin; use App\Models\Plugin;
use Livewire\Component; use Livewire\Volt\Component;
new class extends Component new class extends Component {
{
public Plugin $plugin; public Plugin $plugin;
public string $name; public string $name;
public array $checked_devices = []; public array $checked_devices = [];
public array $device_playlists = []; public array $device_playlists = [];
public array $device_playlist_names = []; public array $device_playlist_names = [];
public array $device_weekdays = []; public array $device_weekdays = [];
public array $device_active_from = []; public array $device_active_from = [];
public array $device_active_until = []; public array $device_active_until = [];
public function mount(): void public function mount(): void
@ -46,6 +38,7 @@ new class extends Component
$this->plugin->update(['name' => $this->name]); $this->plugin->update(['name' => $this->name]);
} }
public function addToPlaylist() public function addToPlaylist()
{ {
$this->validate([ $this->validate([
@ -53,16 +46,14 @@ new class extends Component
]); ]);
foreach ($this->checked_devices as $deviceId) { foreach ($this->checked_devices as $deviceId) {
if (! isset($this->device_playlists[$deviceId]) || empty($this->device_playlists[$deviceId])) { if (!isset($this->device_playlists[$deviceId]) || empty($this->device_playlists[$deviceId])) {
$this->addError('device_playlists.'.$deviceId, 'Please select a playlist for each device.'); $this->addError('device_playlists.' . $deviceId, 'Please select a playlist for each device.');
return; return;
} }
if ($this->device_playlists[$deviceId] === 'new') { if ($this->device_playlists[$deviceId] === 'new') {
if (! isset($this->device_playlist_names[$deviceId]) || empty($this->device_playlist_names[$deviceId])) { if (!isset($this->device_playlist_names[$deviceId]) || empty($this->device_playlist_names[$deviceId])) {
$this->addError('device_playlist_names.'.$deviceId, 'Playlist name is required when creating a new playlist.'); $this->addError('device_playlist_names.' . $deviceId, 'Playlist name is required when creating a new playlist.');
return; return;
} }
} }
@ -72,15 +63,15 @@ new class extends Component
$playlist = null; $playlist = null;
if ($this->device_playlists[$deviceId] === 'new') { if ($this->device_playlists[$deviceId] === 'new') {
$playlist = App\Models\Playlist::create([ $playlist = \App\Models\Playlist::create([
'device_id' => $deviceId, 'device_id' => $deviceId,
'name' => $this->device_playlist_names[$deviceId], 'name' => $this->device_playlist_names[$deviceId],
'weekdays' => ! empty($this->device_weekdays[$deviceId] ?? null) ? $this->device_weekdays[$deviceId] : null, 'weekdays' => !empty($this->device_weekdays[$deviceId] ?? null) ? $this->device_weekdays[$deviceId] : null,
'active_from' => $this->device_active_from[$deviceId] ?? null, 'active_from' => $this->device_active_from[$deviceId] ?? null,
'active_until' => $this->device_active_until[$deviceId] ?? null, 'active_until' => $this->device_active_until[$deviceId] ?? null,
]); ]);
} else { } else {
$playlist = App\Models\Playlist::findOrFail($this->device_playlists[$deviceId]); $playlist = \App\Models\Playlist::findOrFail($this->device_playlists[$deviceId]);
} }
$maxOrder = $playlist->items()->max('order') ?? 0; $maxOrder = $playlist->items()->max('order') ?? 0;
@ -105,17 +96,16 @@ new class extends Component
public function getDevicePlaylists($deviceId) public function getDevicePlaylists($deviceId)
{ {
return App\Models\Playlist::where('device_id', $deviceId)->get(); return \App\Models\Playlist::where('device_id', $deviceId)->get();
} }
public function hasAnyPlaylistSelected(): bool public function hasAnyPlaylistSelected(): bool
{ {
foreach ($this->checked_devices as $deviceId) { foreach ($this->checked_devices as $deviceId) {
if (isset($this->device_playlists[$deviceId]) && ! empty($this->device_playlists[$deviceId])) { if (isset($this->device_playlists[$deviceId]) && !empty($this->device_playlists[$deviceId])) {
return true; return true;
} }
} }
return false; return false;
} }
@ -128,14 +118,14 @@ new class extends Component
public function getImagePath(): ?string public function getImagePath(): ?string
{ {
if (! $this->plugin->current_image) { if (!$this->plugin->current_image) {
return null; return null;
} }
$extensions = ['png', 'bmp']; $extensions = ['png', 'bmp'];
foreach ($extensions as $ext) { foreach ($extensions as $ext) {
$path = 'images/generated/'.$this->plugin->current_image.'.'.$ext; $path = 'images/generated/'.$this->plugin->current_image.'.'.$ext;
if (Illuminate\Support\Facades\Storage::disk('public')->exists($path)) { if (\Illuminate\Support\Facades\Storage::disk('public')->exists($path)) {
return $path; return $path;
} }
} }

View file

@ -1,13 +1,11 @@
<?php <?php
use App\Models\Plugin; use App\Models\Plugin;
use Livewire\Volt\Component;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Livewire\Component;
new class extends Component new class extends Component {
{
public string $name = ''; public string $name = '';
public array $instances = []; public array $instances = [];
protected $rules = [ protected $rules = [

View file

@ -2,38 +2,32 @@
use App\Console\Commands\ExampleRecipesSeederCommand; use App\Console\Commands\ExampleRecipesSeederCommand;
use App\Services\PluginImportService; use App\Services\PluginImportService;
use Illuminate\Support\Str; use Livewire\Volt\Component;
use Livewire\Component;
use Livewire\WithFileUploads; use Livewire\WithFileUploads;
use Illuminate\Support\Str;
new class extends Component new class extends Component {
{
use WithFileUploads; use WithFileUploads;
public string $name; public string $name;
public int $data_stale_minutes = 60; public int $data_stale_minutes = 60;
public string $data_strategy = "polling";
public string $data_strategy = 'polling';
public string $polling_url; public string $polling_url;
public string $polling_verb = "get";
public string $polling_verb = 'get';
public $polling_header; public $polling_header;
public $polling_body; public $polling_body;
public array $plugins; public array $plugins;
public $zipFile; public $zipFile;
public string $sortBy = 'date_asc'; public string $sortBy = 'date_asc';
public array $native_plugins = [ public array $native_plugins = [
'markup' => ['name' => 'Markup', 'flux_icon_name' => 'code-bracket', 'detail_view_route' => 'plugins.markup'], 'markup' =>
'api' => ['name' => 'API', 'flux_icon_name' => 'braces', 'detail_view_route' => 'plugins.api'], ['name' => 'Markup', 'flux_icon_name' => 'code-bracket', 'detail_view_route' => 'plugins.markup'],
'image-webhook' => ['name' => 'Image Webhook', 'flux_icon_name' => 'photo', 'detail_view_route' => 'plugins.image-webhook'], 'api' =>
['name' => 'API', 'flux_icon_name' => 'braces', 'detail_view_route' => 'plugins.api'],
'image-webhook' =>
['name' => 'Image Webhook', 'flux_icon_name' => 'photo', 'detail_view_route' => 'plugins.image-webhook'],
]; ];
protected $rules = [ protected $rules = [
@ -66,31 +60,29 @@ new class extends Component
switch ($this->sortBy) { switch ($this->sortBy) {
case 'name_asc': case 'name_asc':
usort($pluginsToSort, function ($a, $b) { usort($pluginsToSort, function($a, $b) {
return strcasecmp($a['name'] ?? '', $b['name'] ?? ''); return strcasecmp($a['name'] ?? '', $b['name'] ?? '');
}); });
break; break;
case 'name_desc': case 'name_desc':
usort($pluginsToSort, function ($a, $b) { usort($pluginsToSort, function($a, $b) {
return strcasecmp($b['name'] ?? '', $a['name'] ?? ''); return strcasecmp($b['name'] ?? '', $a['name'] ?? '');
}); });
break; break;
case 'date_desc': case 'date_desc':
usort($pluginsToSort, function ($a, $b) { usort($pluginsToSort, function($a, $b) {
$aDate = $a['created_at'] ?? '1970-01-01'; $aDate = $a['created_at'] ?? '1970-01-01';
$bDate = $b['created_at'] ?? '1970-01-01'; $bDate = $b['created_at'] ?? '1970-01-01';
return strcmp($bDate, $aDate); return strcmp($bDate, $aDate);
}); });
break; break;
case 'date_asc': case 'date_asc':
usort($pluginsToSort, function ($a, $b) { usort($pluginsToSort, function($a, $b) {
$aDate = $a['created_at'] ?? '1970-01-01'; $aDate = $a['created_at'] ?? '1970-01-01';
$bDate = $b['created_at'] ?? '1970-01-01'; $bDate = $b['created_at'] ?? '1970-01-01';
return strcmp($aDate, $bDate); return strcmp($aDate, $bDate);
}); });
break; break;
@ -121,7 +113,7 @@ new class extends Component
abort_unless(auth()->user() !== null, 403); abort_unless(auth()->user() !== null, 403);
$this->validate(); $this->validate();
App\Models\Plugin::create([ \App\Models\Plugin::create([
'uuid' => Str::uuid(), 'uuid' => Str::uuid(),
'user_id' => auth()->id(), 'user_id' => auth()->id(),
'name' => $this->name, 'name' => $this->name,
@ -145,6 +137,7 @@ new class extends Component
$this->refreshPlugins(); $this->refreshPlugins();
} }
public function importZip(PluginImportService $pluginImportService): void public function importZip(PluginImportService $pluginImportService): void
{ {
abort_unless(auth()->user() !== null, 403); abort_unless(auth()->user() !== null, 403);
@ -160,10 +153,11 @@ new class extends Component
$this->reset(['zipFile']); $this->reset(['zipFile']);
Flux::modal('import-zip')->close(); Flux::modal('import-zip')->close();
} catch (Exception $e) { } catch (\Exception $e) {
$this->addError('zipFile', 'Error installing plugin: '.$e->getMessage()); $this->addError('zipFile', 'Error installing plugin: ' . $e->getMessage());
} }
} }
}; };
?> ?>

View file

@ -2,16 +2,14 @@
use App\Jobs\GenerateScreenJob; use App\Jobs\GenerateScreenJob;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Livewire\Component; use Livewire\Volt\Component;
new class extends Component {
new class extends Component
{
public string $blade_code = ''; public string $blade_code = '';
public bool $isLoading = false; public bool $isLoading = false;
public Collection $devices; public Collection $devices;
public array $checked_devices; public array $checked_devices;
public function mount() public function mount()
@ -19,16 +17,17 @@ new class extends Component
$this->devices = auth()->user()->devices->pluck('id', 'name'); $this->devices = auth()->user()->devices->pluck('id', 'name');
} }
public function submit() public function submit()
{ {
$this->isLoading = true; $this->isLoading = true;
$this->validate([ $this->validate([
'checked_devices' => 'required|array', 'checked_devices' => 'required|array',
'blade_code' => 'required|string', 'blade_code' => 'required|string'
]); ]);
// only devices that are owned by the user //only devices that are owned by the user
$this->checked_devices = array_intersect($this->checked_devices, auth()->user()->devices->pluck('id')->toArray()); $this->checked_devices = array_intersect($this->checked_devices, auth()->user()->devices->pluck('id')->toArray());
try { try {
@ -36,7 +35,7 @@ new class extends Component
foreach ($this->checked_devices as $device) { foreach ($this->checked_devices as $device) {
GenerateScreenJob::dispatchSync($device, null, $rendered); GenerateScreenJob::dispatchSync($device, null, $rendered);
} }
} catch (Exception $e) { } catch (\Exception $e) {
$this->addError('generate_screen', $e->getMessage()); $this->addError('generate_screen', $e->getMessage());
} }
@ -67,7 +66,7 @@ new class extends Component
public function renderHelloWorld(): string public function renderHelloWorld(): string
{ {
return <<<'HTML' return <<<HTML
<x-trmnl::screen> <x-trmnl::screen>
<x-trmnl::view> <x-trmnl::view>
<x-trmnl::layout> <x-trmnl::layout>
@ -85,7 +84,7 @@ HTML;
public function renderQuote(): string public function renderQuote(): string
{ {
return <<<'HTML' return <<<HTML
<x-trmnl::screen> <x-trmnl::screen>
<x-trmnl::view> <x-trmnl::view>
<x-trmnl::layout> <x-trmnl::layout>
@ -103,7 +102,7 @@ HTML;
public function renderTrainMonitor() public function renderTrainMonitor()
{ {
return <<<'HTML' return <<<HTML
<x-trmnl::screen> <x-trmnl::screen>
<x-trmnl::view> <x-trmnl::view>
<x-trmnl::layout> <x-trmnl::layout>
@ -137,7 +136,7 @@ HTML;
public function renderHomeAssistant() public function renderHomeAssistant()
{ {
return <<<'HTML' return <<<HTML
<x-trmnl::screen> <x-trmnl::screen>
<x-trmnl::view> <x-trmnl::view>
<x-trmnl::layout class="layout--col gap--space-between"> <x-trmnl::layout class="layout--col gap--space-between">
@ -163,6 +162,8 @@ HTML;
HTML; HTML;
} }
}; };
?> ?>

View file

@ -1,68 +1,44 @@
<?php <?php
use App\Models\Device; use App\Models\Device;
use App\Models\DeviceModel;
use App\Models\Plugin; use App\Models\Plugin;
use Illuminate\Support\Arr; use App\Models\DeviceModel;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Blade;
use Keepsuit\Liquid\Exceptions\LiquidException; use Keepsuit\Liquid\Exceptions\LiquidException;
use Livewire\Attributes\Computed; use Livewire\Volt\Component;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Livewire\Attributes\On; use Livewire\Attributes\On;
use Livewire\Component; use Livewire\Attributes\Computed;
new class extends Component new class extends Component {
{
public Plugin $plugin; public Plugin $plugin;
public string|null $markup_code;
public ?string $markup_code; public string|null $view_content;
public string|null $markup_language;
public ?string $view_content;
public ?string $markup_language;
public string $name; public string $name;
public bool $no_bleed = false; public bool $no_bleed = false;
public bool $dark_mode = false; public bool $dark_mode = false;
public int $data_stale_minutes; public int $data_stale_minutes;
public string $data_strategy; public string $data_strategy;
public string|null $polling_url;
public ?string $polling_url;
public string $polling_verb; public string $polling_verb;
public string|null $polling_header;
public ?string $polling_header; public string|null $polling_body;
public ?string $polling_body;
public $data_payload; public $data_payload;
public ?Carbon $data_payload_updated_at; public ?Carbon $data_payload_updated_at;
public array $checked_devices = []; public array $checked_devices = [];
public array $device_playlists = []; public array $device_playlists = [];
public array $device_playlist_names = []; public array $device_playlist_names = [];
public array $device_weekdays = []; public array $device_weekdays = [];
public array $device_active_from = []; public array $device_active_from = [];
public array $device_active_until = []; public array $device_active_until = [];
public string $mashup_layout = 'full'; public string $mashup_layout = 'full';
public array $mashup_plugins = []; public array $mashup_plugins = [];
public array $configuration_template = []; public array $configuration_template = [];
public ?int $preview_device_model_id = null; public ?int $preview_device_model_id = null;
public string $preview_size = 'full'; public string $preview_size = 'full';
public function mount(): void public function mount(): void
@ -74,10 +50,10 @@ new class extends Component
if ($this->plugin->render_markup_view) { if ($this->plugin->render_markup_view) {
try { try {
$basePath = resource_path('views/'.str_replace('.', '/', $this->plugin->render_markup_view)); $basePath = resource_path('views/' . str_replace('.', '/', $this->plugin->render_markup_view));
$paths = [ $paths = [
$basePath.'.blade.php', $basePath . '.blade.php',
$basePath.'.liquid', $basePath . '.liquid',
]; ];
$this->view_content = null; $this->view_content = null;
@ -87,7 +63,7 @@ new class extends Component
break; break;
} }
} }
} catch (Exception $e) { } catch (\Exception $e) {
$this->view_content = null; $this->view_content = null;
} }
} else { } else {
@ -127,7 +103,7 @@ new class extends Component
$this->validate(); $this->validate();
$this->plugin->update([ $this->plugin->update([
'render_markup' => $this->markup_code ?? null, 'render_markup' => $this->markup_code ?? null,
'markup_language' => $this->markup_language ?? null, 'markup_language' => $this->markup_language ?? null
]); ]);
} }
@ -160,7 +136,7 @@ new class extends Component
$this->validatePollingUrl(); $this->validatePollingUrl();
$validated = $this->validate(); $validated = $this->validate();
$validated['data_payload'] = json_decode(Arr::get($validated, 'data_payload'), true); $validated['data_payload'] = json_decode(Arr::get($validated,'data_payload'), true);
$this->plugin->update($validated); $this->plugin->update($validated);
foreach ($this->configuration_template as $fieldKey => $field) { foreach ($this->configuration_template as $fieldKey => $field) {
@ -168,7 +144,7 @@ new class extends Component
continue; continue;
} }
if (! isset($this->multiValues[$fieldKey])) { if (!isset($this->multiValues[$fieldKey])) {
continue; continue;
} }
@ -179,15 +155,15 @@ new class extends Component
protected function validatePollingUrl(): void protected function validatePollingUrl(): void
{ {
if ($this->data_strategy === 'polling' && ! empty($this->polling_url)) { if ($this->data_strategy === 'polling' && !empty($this->polling_url)) {
try { try {
$resolvedUrl = $this->plugin->resolveLiquidVariables($this->polling_url); $resolvedUrl = $this->plugin->resolveLiquidVariables($this->polling_url);
if (! filter_var($resolvedUrl, FILTER_VALIDATE_URL)) { if (!filter_var($resolvedUrl, FILTER_VALIDATE_URL)) {
$this->addError('polling_url', 'The polling URL must be a valid URL after resolving configuration variables.'); $this->addError('polling_url', 'The polling URL must be a valid URL after resolving configuration variables.');
} }
} catch (Exception $e) { } catch (\Exception $e) {
$this->addError('polling_url', 'Error resolving Liquid variables: '.$e->getMessage().$e->getPrevious()?->getMessage()); $this->addError('polling_url', 'Error resolving Liquid variables: ' . $e->getMessage() . $e->getPrevious()?->getMessage());
} }
} }
} }
@ -201,8 +177,8 @@ new class extends Component
$this->data_payload = json_encode($this->plugin->data_payload, JSON_PRETTY_PRINT); $this->data_payload = json_encode($this->plugin->data_payload, JSON_PRETTY_PRINT);
$this->data_payload_updated_at = $this->plugin->data_payload_updated_at; $this->data_payload_updated_at = $this->plugin->data_payload_updated_at;
} catch (Exception $e) { } catch (\Exception $e) {
$this->dispatch('data-update-error', message: $e->getMessage().$e->getPrevious()?->getMessage()); $this->dispatch('data-update-error', message: $e->getMessage() . $e->getPrevious()?->getMessage());
} }
} }
} }
@ -236,17 +212,15 @@ new class extends Component
// Validate that each checked device has a playlist selected // Validate that each checked device has a playlist selected
foreach ($this->checked_devices as $deviceId) { foreach ($this->checked_devices as $deviceId) {
if (! isset($this->device_playlists[$deviceId]) || empty($this->device_playlists[$deviceId])) { if (!isset($this->device_playlists[$deviceId]) || empty($this->device_playlists[$deviceId])) {
$this->addError('device_playlists.'.$deviceId, 'Please select a playlist for each device.'); $this->addError('device_playlists.' . $deviceId, 'Please select a playlist for each device.');
return; return;
} }
// If creating new playlist, validate required fields // If creating new playlist, validate required fields
if ($this->device_playlists[$deviceId] === 'new') { if ($this->device_playlists[$deviceId] === 'new') {
if (! isset($this->device_playlist_names[$deviceId]) || empty($this->device_playlist_names[$deviceId])) { if (!isset($this->device_playlist_names[$deviceId]) || empty($this->device_playlist_names[$deviceId])) {
$this->addError('device_playlist_names.'.$deviceId, 'Playlist name is required when creating a new playlist.'); $this->addError('device_playlist_names.' . $deviceId, 'Playlist name is required when creating a new playlist.');
return; return;
} }
} }
@ -257,15 +231,15 @@ new class extends Component
if ($this->device_playlists[$deviceId] === 'new') { if ($this->device_playlists[$deviceId] === 'new') {
// Create new playlist // Create new playlist
$playlist = App\Models\Playlist::create([ $playlist = \App\Models\Playlist::create([
'device_id' => $deviceId, 'device_id' => $deviceId,
'name' => $this->device_playlist_names[$deviceId], 'name' => $this->device_playlist_names[$deviceId],
'weekdays' => ! empty($this->device_weekdays[$deviceId] ?? null) ? $this->device_weekdays[$deviceId] : null, 'weekdays' => !empty($this->device_weekdays[$deviceId] ?? null) ? $this->device_weekdays[$deviceId] : null,
'active_from' => $this->device_active_from[$deviceId] ?? null, 'active_from' => $this->device_active_from[$deviceId] ?? null,
'active_until' => $this->device_active_until[$deviceId] ?? null, 'active_until' => $this->device_active_until[$deviceId] ?? null,
]); ]);
} else { } else {
$playlist = App\Models\Playlist::findOrFail($this->device_playlists[$deviceId]); $playlist = \App\Models\Playlist::findOrFail($this->device_playlists[$deviceId]);
} }
// Add plugin to playlist // Add plugin to playlist
@ -279,11 +253,11 @@ new class extends Component
} else { } else {
// Create mashup // Create mashup
$pluginIds = array_merge([$this->plugin->id], array_map('intval', $this->mashup_plugins)); $pluginIds = array_merge([$this->plugin->id], array_map('intval', $this->mashup_plugins));
App\Models\PlaylistItem::createMashup( \App\Models\PlaylistItem::createMashup(
$playlist, $playlist,
$this->mashup_layout, $this->mashup_layout,
$pluginIds, $pluginIds,
$this->plugin->name.' Mashup', $this->plugin->name . ' Mashup',
$maxOrder + 1 $maxOrder + 1
); );
} }
@ -297,24 +271,23 @@ new class extends Component
'device_active_from', 'device_active_from',
'device_active_until', 'device_active_until',
'mashup_layout', 'mashup_layout',
'mashup_plugins', 'mashup_plugins'
]); ]);
Flux::modal('add-to-playlist')->close(); Flux::modal('add-to-playlist')->close();
} }
public function getDevicePlaylists($deviceId) public function getDevicePlaylists($deviceId)
{ {
return App\Models\Playlist::where('device_id', $deviceId)->get(); return \App\Models\Playlist::where('device_id', $deviceId)->get();
} }
public function hasAnyPlaylistSelected(): bool public function hasAnyPlaylistSelected(): bool
{ {
foreach ($this->checked_devices as $deviceId) { foreach ($this->checked_devices as $deviceId) {
if (isset($this->device_playlists[$deviceId]) && ! empty($this->device_playlists[$deviceId])) { if (isset($this->device_playlists[$deviceId]) && !empty($this->device_playlists[$deviceId])) {
return true; return true;
} }
} }
return false; return false;
} }
@ -342,7 +315,7 @@ new class extends Component
public function renderLayoutWithTitleBar(): string public function renderLayoutWithTitleBar(): string
{ {
if ($this->markup_language === 'liquid') { if ($this->markup_language === 'liquid') {
return <<<'HTML' return <<<HTML
<div class="view view--{{ size }}"> <div class="view view--{{ size }}">
<div class="layout"> <div class="layout">
<!-- ADD YOUR CONTENT HERE--> <!-- ADD YOUR CONTENT HERE-->
@ -354,9 +327,9 @@ new class extends Component
HTML; HTML;
} }
return <<<'HTML' return <<<HTML
@props(['size' => 'full']) @props(['size' => 'full'])
<x-trmnl::view size="{{$size}}"> <x-trmnl::view size="{{\$size}}">
<x-trmnl::layout> <x-trmnl::layout>
<!-- ADD YOUR CONTENT HERE--> <!-- ADD YOUR CONTENT HERE-->
</x-trmnl::layout> </x-trmnl::layout>
@ -368,7 +341,7 @@ HTML;
public function renderLayoutBlank(): string public function renderLayoutBlank(): string
{ {
if ($this->markup_language === 'liquid') { if ($this->markup_language === 'liquid') {
return <<<'HTML' return <<<HTML
<div class="view view--{{ size }}"> <div class="view view--{{ size }}">
<div class="layout"> <div class="layout">
<!-- ADD YOUR CONTENT HERE--> <!-- ADD YOUR CONTENT HERE-->
@ -377,9 +350,9 @@ HTML;
HTML; HTML;
} }
return <<<'HTML' return <<<HTML
@props(['size' => 'full']) @props(['size' => 'full'])
<x-trmnl::view size="{{$size}}"> <x-trmnl::view size="{{\$size}}">
<x-trmnl::layout> <x-trmnl::layout>
<!-- ADD YOUR CONTENT HERE--> <!-- ADD YOUR CONTENT HERE-->
</x-trmnl::layout> </x-trmnl::layout>
@ -405,12 +378,12 @@ HTML;
$this->dispatch('preview-updated', preview: $previewMarkup); $this->dispatch('preview-updated', preview: $previewMarkup);
} catch (LiquidException $e) { } catch (LiquidException $e) {
$this->dispatch('preview-error', message: $e->toLiquidErrorMessage()); $this->dispatch('preview-error', message: $e->toLiquidErrorMessage());
} catch (Exception $e) { } catch (\Exception $e) {
$this->dispatch('preview-error', message: $e->getMessage()); $this->dispatch('preview-error', message: $e->getMessage());
} }
} }
private function createPreviewDevice(): Device private function createPreviewDevice(): \App\Models\Device
{ {
$deviceModel = DeviceModel::with(['palette'])->find($this->preview_device_model_id) $deviceModel = DeviceModel::with(['palette'])->find($this->preview_device_model_id)
?? DeviceModel::with(['palette'])->first(); ?? DeviceModel::with(['palette'])->first();
@ -461,17 +434,18 @@ HTML;
#[Computed] #[Computed]
private function parsedUrls() private function parsedUrls()
{ {
if (! isset($this->polling_url)) { if (!isset($this->polling_url)) {
return null; return null;
} }
try { try {
return $this->plugin->resolveLiquidVariables($this->polling_url); return $this->plugin->resolveLiquidVariables($this->polling_url);
} catch (Exception $e) { } catch (\Exception $e) {
return 'PARSE_ERROR: '.$e->getMessage(); return 'PARSE_ERROR: ' . $e->getMessage();
} }
} }
} }
?> ?>

View file

@ -2,19 +2,15 @@
use App\Models\Plugin; use App\Models\Plugin;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Livewire\Component; use Livewire\Volt\Component;
/* /*
* This component contains the TRMNL Plugin Settings modal * This component contains the TRMNL Plugin Settings modal
*/ */
new class extends Component new class extends Component {
{
public Plugin $plugin; public Plugin $plugin;
public string|null $trmnlp_id = null;
public ?string $trmnlp_id = null; public string|null $uuid = null;
public ?string $uuid = null;
public bool $alias = false; public bool $alias = false;
public int $resetIndex = 0; public int $resetIndex = 0;
@ -57,7 +53,7 @@ new class extends Component
{ {
return url("/api/display/{$this->uuid}/alias"); return url("/api/display/{$this->uuid}/alias");
} }
}; ?> };?>
<flux:modal name="trmnlp-settings" class="min-w-[400px] space-y-6"> <flux:modal name="trmnlp-settings" class="min-w-[400px] space-y-6">
<div wire:key="trmnlp-settings-form-{{ $resetIndex }}" class="space-y-6"> <div wire:key="trmnlp-settings-form-{{ $resetIndex }}" class="space-y-6">

View file

@ -0,0 +1,19 @@
<?php
use Livewire\Volt\Component;
new class extends Component {
//
}; ?>
<div class="flex flex-col items-start">
@include('partials.settings-heading')
<x-settings.layout heading="Appearance" subheading="Update your account's appearance settings">
<flux:radio.group x-data variant="segmented" x-model="$flux.appearance">
<flux:radio value="light" icon="sun">Light</flux:radio>
<flux:radio value="dark" icon="moon">Dark</flux:radio>
<flux:radio value="system" icon="computer-desktop">System</flux:radio>
</flux:radio.group>
</x-settings.layout>
</div>

View file

@ -0,0 +1,60 @@
<?php
use App\Livewire\Actions\Logout;
use Illuminate\Support\Facades\Auth;
use Livewire\Volt\Component;
new class extends Component {
public string $password = '';
/**
* Delete the currently authenticated user.
*/
public function deleteUser(Logout $logout): void
{
$this->validate([
'password' => ['required', 'string', 'current_password'],
]);
tap(Auth::user(), $logout(...))->delete();
$this->redirect('/', navigate: true);
}
}; ?>
<section class="mt-10 space-y-6">
<div class="relative mb-5">
<flux:heading>{{ __('Delete Account') }}</flux:heading>
<flux:subheading>{{ __('Delete your account and all of its resources') }}</flux:subheading>
</div>
<flux:modal.trigger name="confirm-user-deletion">
<flux:button {{--variant="danger" --}} x-data="" x-on:click.prevent="$dispatch('open-modal', 'confirm-user-deletion')">
{{ __('Delete Account') }}
</flux:button>
</flux:modal.trigger>
<flux:modal name="confirm-user-deletion" :show="$errors->isNotEmpty()" focusable class="max-w-lg space-y-6">
<form wire:submit="deleteUser">
<h2 class="text-lg font-medium text-gray-900">
{{ __('Are you sure you want to delete your account?') }}
</h2>
<p class="mt-1 text-sm text-gray-600">
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }}
</p>
<div class="mt-6">
<flux:input wire:model="password" id="password" label="{{ __('Password') }}" type="password" name="password" />
</div>
<div class="mt-6 flex justify-end space-x-2">
<flux:modal.close>
<flux:button variant="filled">{{ __('Cancel') }}</flux:button>
</flux:modal.close>
<flux:button variant="danger" type="submit">{{ __('Delete Account') }}</flux:button>
</div>
</form>
</flux:modal>
</section>

View file

@ -1,19 +1,14 @@
<?php <?php
use App\Concerns\PasswordValidationRules;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Livewire\Component; use Livewire\Volt\Component;
new class extends Component
{
use PasswordValidationRules;
new class extends Component {
public string $current_password = ''; public string $current_password = '';
public string $password = ''; public string $password = '';
public string $password_confirmation = ''; public string $password_confirmation = '';
/** /**
@ -23,8 +18,8 @@ new class extends Component
{ {
try { try {
$validated = $this->validate([ $validated = $this->validate([
'current_password' => $this->currentPasswordRules(), 'current_password' => ['required', 'string', 'current_password'],
'password' => $this->passwordRules(), 'password' => ['required', 'string', Password::defaults(), 'confirmed'],
]); ]);
} catch (ValidationException $e) { } catch (ValidationException $e) {
$this->reset('current_password', 'password', 'password_confirmation'); $this->reset('current_password', 'password', 'password_confirmation');
@ -33,7 +28,7 @@ new class extends Component
} }
Auth::user()->update([ Auth::user()->update([
'password' => $validated['password'], 'password' => Hash::make($validated['password']),
]); ]);
$this->reset('current_password', 'password', 'password_confirmation'); $this->reset('current_password', 'password', 'password_confirmation');
@ -45,37 +40,39 @@ new class extends Component
<section class="w-full"> <section class="w-full">
@include('partials.settings-heading') @include('partials.settings-heading')
<flux:heading class="sr-only">{{ __('Password Settings') }}</flux:heading> <x-settings.layout heading="Update password" subheading="Ensure your account is using a long, random password to stay secure">
<form wire:submit="updatePassword" class="mt-6 space-y-6">
<x-pages::settings.layout :heading="__('Update password')" :subheading="__('Ensure your account is using a long, random password to stay secure')">
<form method="POST" wire:submit="updatePassword" class="mt-6 space-y-6">
<flux:input <flux:input
wire:model="current_password" wire:model="current_password"
:label="__('Current password')" id="update_password_current_passwordpassword"
label="{{ __('Current password') }}"
type="password" type="password"
name="current_password"
required required
autocomplete="current-password" autocomplete="current-password"
/> />
<flux:input <flux:input
wire:model="password" wire:model="password"
:label="__('New password')" id="update_password_password"
label="{{ __('New password') }}"
type="password" type="password"
name="password"
required required
autocomplete="new-password" autocomplete="new-password"
/> />
<flux:input <flux:input
wire:model="password_confirmation" wire:model="password_confirmation"
:label="__('Confirm Password')" id="update_password_password_confirmation"
label="{{ __('Confirm Password') }}"
type="password" type="password"
name="password_confirmation"
required required
autocomplete="new-password" autocomplete="new-password"
/> />
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="flex items-center justify-end"> <div class="flex items-center justify-end">
<flux:button variant="primary" type="submit" class="w-full" data-test="update-password-button"> <flux:button variant="primary" type="submit" class="w-full">{{ __('Save') }}</flux:button>
{{ __('Save') }}
</flux:button>
</div> </div>
<x-action-message class="me-3" on="password-updated"> <x-action-message class="me-3" on="password-updated">
@ -83,5 +80,5 @@ new class extends Component
</x-action-message> </x-action-message>
</div> </div>
</form> </form>
</x-pages::settings.layout> </x-settings.layout>
</section> </section>

View file

@ -1,11 +1,14 @@
<?php <?php
use App\Models\User;
use App\Models\Device;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Livewire\Component; use Livewire\Volt\Component;
new class extends Component new class extends Component {
{
public ?int $assign_new_device_id = null; public ?int $assign_new_device_id = null;
public ?string $timezone = null; public ?string $timezone = null;
@ -42,7 +45,7 @@ new class extends Component
<section class="w-full"> <section class="w-full">
@include('partials.settings-heading') @include('partials.settings-heading')
<x-pages::settings.layout heading="Preferences" subheading="Update your preferences"> <x-settings.layout heading="Preferences" subheading="Update your preferences">
<form wire:submit="updatePreferences" class="my-6 w-full space-y-6"> <form wire:submit="updatePreferences" class="my-6 w-full space-y-6">
<flux:select wire:model="timezone" label="Timezone"> <flux:select wire:model="timezone" label="Timezone">
@ -72,5 +75,5 @@ new class extends Component
</div> </div>
</form> </form>
</x-pages::settings.layout> </x-settings.layout>
</section> </section>

View file

@ -1,19 +1,13 @@
<?php <?php
use App\Concerns\ProfileValidationRules;
use App\Models\User; use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session; use Illuminate\Support\Facades\Session;
use Livewire\Attributes\Computed; use Illuminate\Validation\Rule;
use Livewire\Component; use Livewire\Volt\Component;
new class extends Component
{
use ProfileValidationRules;
new class extends Component {
public string $name = ''; public string $name = '';
public string $email = ''; public string $email = '';
/** /**
@ -32,7 +26,18 @@ new class extends Component
{ {
$user = Auth::user(); $user = Auth::user();
$validated = $this->validate($this->profileRules($user->id)); $validated = $this->validate([
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique(User::class)->ignore($user->id)
],
]);
$user->fill($validated); $user->fill($validated);
@ -62,47 +67,35 @@ new class extends Component
Session::flash('status', 'verification-link-sent'); Session::flash('status', 'verification-link-sent');
} }
#[Computed]
public function hasUnverifiedEmail(): bool
{
return Auth::user() instanceof MustVerifyEmail && ! Auth::user()->hasVerifiedEmail();
}
#[Computed]
public function showDeleteUser(): bool
{
return ! Auth::user() instanceof MustVerifyEmail
|| (Auth::user() instanceof MustVerifyEmail && Auth::user()->hasVerifiedEmail());
}
}; ?> }; ?>
<section class="w-full"> <section class="w-full">
@include('partials.settings-heading') @include('partials.settings-heading')
<flux:heading class="sr-only">{{ __('Profile Settings') }}</flux:heading> <x-settings.layout heading="Profile" subheading="Update your name and email address">
<x-pages::settings.layout :heading="__('Profile')" :subheading="__('Update your name and email address')">
<form wire:submit="updateProfileInformation" class="my-6 w-full space-y-6"> <form wire:submit="updateProfileInformation" class="my-6 w-full space-y-6">
<flux:input wire:model="name" :label="__('Name')" type="text" required autofocus autocomplete="name" /> <flux:input wire:model="name" label="{{ __('Name') }}" type="text" name="name" required autofocus autocomplete="name" />
<div> <div>
<flux:input wire:model="email" :label="__('Email')" type="email" required autocomplete="email" /> <flux:input wire:model="email" label="{{ __('Email') }}" type="email" name="email" required autocomplete="email" />
@if ($this->hasUnverifiedEmail) @if (auth()->user() instanceof \Illuminate\Contracts\Auth\MustVerifyEmail &&! auth()->user()->hasVerifiedEmail())
<div> <div>
<flux:text class="mt-4"> <p class="mt-2 text-sm text-gray-800">
{{ __('Your email address is unverified.') }} {{ __('Your email address is unverified.') }}
<flux:link class="text-sm cursor-pointer" wire:click.prevent="resendVerificationNotification"> <button
wire:click.prevent="resendVerificationNotification"
class="rounded-md text-sm text-gray-600 underline hover:text-gray-900 focus:outline-hidden focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
{{ __('Click here to re-send the verification email.') }} {{ __('Click here to re-send the verification email.') }}
</flux:link> </button>
</flux:text> </p>
@if (session('status') === 'verification-link-sent') @if (session('status') === 'verification-link-sent')
<flux:text class="mt-2 font-medium !dark:text-green-400 !text-green-600"> <p class="mt-2 text-sm font-medium text-green-600">
{{ __('A new verification link has been sent to your email address.') }} {{ __('A new verification link has been sent to your email address.') }}
</flux:text> </p>
@endif @endif
</div> </div>
@endif @endif
@ -110,9 +103,7 @@ new class extends Component
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="flex items-center justify-end"> <div class="flex items-center justify-end">
<flux:button variant="primary" type="submit" class="w-full" data-test="update-profile-button"> <flux:button variant="primary" type="submit" class="w-full">{{ __('Save') }}</flux:button>
{{ __('Save') }}
</flux:button>
</div> </div>
<x-action-message class="me-3" on="profile-updated"> <x-action-message class="me-3" on="profile-updated">
@ -121,8 +112,6 @@ new class extends Component
</div> </div>
</form> </form>
@if ($this->showDeleteUser) <livewire:settings.delete-user-form />
<livewire:pages::settings.delete-user-form /> </x-settings.layout>
@endif
</x-pages::settings.layout>
</section> </section>

View file

@ -1,13 +1,7 @@
<?php
use Livewire\Component;
new class extends Component {}
?>
<section class="w-full"> <section class="w-full">
@include('partials.settings-heading') @include('partials.settings-heading')
<x-pages::settings.layout heading="Support" subheading="Support the development of this project"> <x-settings.layout heading="Support" subheading="Support the development of this project">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="flex items-center justify-end gap-2"> <div class="flex items-center justify-end gap-2">
@ -35,5 +29,5 @@ new class extends Component {}
</div> </div>
</div> </div>
</x-pages::settings.layout> </x-settings.layout>
</section> </section>

View file

@ -1,28 +0,0 @@
<x-layouts::auth>
<div class="flex flex-col gap-6">
<x-auth-header
:title="__('Confirm password')"
:description="__('This is a secure area of the application. Please confirm your password before continuing.')"
/>
<x-auth-session-status class="text-center" :status="session('status')" />
<form method="POST" action="{{ route('password.confirm.store') }}" class="flex flex-col gap-6">
@csrf
<flux:input
name="password"
:label="__('Password')"
type="password"
required
autocomplete="current-password"
:placeholder="__('Password')"
viewable
/>
<flux:button variant="primary" type="submit" class="w-full" data-test="confirm-password-button">
{{ __('Confirm') }}
</flux:button>
</form>
</div>
</x-layouts::auth>

View file

@ -1,31 +0,0 @@
<x-layouts::auth>
<div class="flex flex-col gap-6">
<x-auth-header :title="__('Forgot password')" :description="__('Enter your email to receive a password reset link')" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form method="POST" action="{{ route('password.email') }}" class="flex flex-col gap-6">
@csrf
<!-- Email Address -->
<flux:input
name="email"
:label="__('Email Address')"
type="email"
required
autofocus
placeholder="email@example.com"
/>
<flux:button variant="primary" type="submit" class="w-full" data-test="email-password-reset-link-button">
{{ __('Email password reset link') }}
</flux:button>
</form>
<div class="space-x-1 rtl:space-x-reverse text-center text-sm text-zinc-400">
<span>{{ __('Or, return to') }}</span>
<flux:link :href="route('login')" wire:navigate>{{ __('log in') }}</flux:link>
</div>
</div>
</x-layouts::auth>

View file

@ -1,85 +0,0 @@
<x-layouts::auth>
<div class="flex flex-col gap-6">
<x-auth-header :title="__('Log in to your account')" :description="__('Enter your email and password below to log in')" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form method="POST" action="{{ route('login.store') }}" class="flex flex-col gap-6">
@csrf
<!-- Email Address -->
<flux:input
name="email"
:label="__('Email address')"
:value="old('email') || app()->isLocal() ? 'admin@example.com' : old('email')"
type="email"
required
autofocus
autocomplete="email"
placeholder="email@example.com"
/>
<!-- Password -->
<div class="relative">
<flux:input
name="password"
:label="__('Password')"
:value="app()->isLocal() ? 'admin@example.com' : ''"
type="password"
required
autocomplete="current-password"
:placeholder="__('Password')"
viewable
/>
@if (Route::has('password.request'))
<x-text-link class="absolute top-0 text-sm end-0" :href="route('password.request')" wire:navigate>
{{ __('Forgot your password?') }}
</x-text-link>
@endif
</div>
<!-- Remember Me -->
<flux:checkbox name="remember" :label="__('Remember me')" :checked="old('remember')" />
<div class="flex items-center justify-end">
<flux:button variant="primary" type="submit" class="w-full" data-test="login-button">
{{ __('Log in') }}
</flux:button>
</div>
</form>
@if (Route::has('register'))
<div class="space-x-1 text-sm text-center rtl:space-x-reverse text-zinc-600 dark:text-zinc-400">
<span>{{ __('Don\'t have an account?') }}</span>
<flux:link :href="route('register')" wire:navigate>{{ __('Sign up') }}</flux:link>
</div>
@endif
@if (config('services.oidc.enabled'))
<div class="relative">
<div class="absolute inset-0 flex items-center">
<span class="w-full border-t border-zinc-300 dark:border-zinc-600"></span>
</div>
<div class="relative flex justify-center text-sm">
<span class="bg-white dark:bg-zinc-900 px-2 text-zinc-500 dark:text-zinc-400">
{{ __('Or') }}
</span>
</div>
</div>
<div class="flex items-center justify-end">
<flux:button
variant="outline"
type="button"
class="w-full"
href="{{ route('auth.oidc.redirect') }}"
>
{{ __('Continue with OIDC') }}
</flux:button>
</div>
@endif
</div>
</x-layouts::auth>

View file

@ -1,67 +0,0 @@
<x-layouts::auth>
<div class="flex flex-col gap-6">
<x-auth-header :title="__('Create an account')" :description="__('Enter your details below to create your account')" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form method="POST" action="{{ route('register.store') }}" class="flex flex-col gap-6">
@csrf
<!-- Name -->
<flux:input
name="name"
:label="__('Name')"
:value="old('name')"
type="text"
required
autofocus
autocomplete="name"
:placeholder="__('Full name')"
/>
<!-- Email Address -->
<flux:input
name="email"
:label="__('Email address')"
:value="old('email')"
type="email"
required
autocomplete="email"
placeholder="email@example.com"
/>
<!-- Password -->
<flux:input
name="password"
:label="__('Password')"
type="password"
required
autocomplete="new-password"
:placeholder="__('Password')"
viewable
/>
<!-- Confirm Password -->
<flux:input
name="password_confirmation"
:label="__('Confirm password')"
type="password"
required
autocomplete="new-password"
:placeholder="__('Confirm password')"
viewable
/>
<div class="flex items-center justify-end">
<flux:button type="submit" variant="primary" class="w-full" data-test="register-user-button">
{{ __('Create account') }}
</flux:button>
</div>
</form>
<div class="space-x-1 rtl:space-x-reverse text-center text-sm text-zinc-600 dark:text-zinc-400">
<span>{{ __('Already have an account?') }}</span>
<flux:link :href="route('login')" wire:navigate>{{ __('Log in') }}</flux:link>
</div>
</div>
</x-layouts::auth>

View file

@ -1,52 +0,0 @@
<x-layouts::auth>
<div class="flex flex-col gap-6">
<x-auth-header :title="__('Reset password')" :description="__('Please enter your new password below')" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form method="POST" action="{{ route('password.update') }}" class="flex flex-col gap-6">
@csrf
<!-- Token -->
<input type="hidden" name="token" value="{{ request()->route('token') }}">
<!-- Email Address -->
<flux:input
name="email"
value="{{ request('email') }}"
:label="__('Email')"
type="email"
required
autocomplete="email"
/>
<!-- Password -->
<flux:input
name="password"
:label="__('Password')"
type="password"
required
autocomplete="new-password"
:placeholder="__('Password')"
viewable
/>
<!-- Confirm Password -->
<flux:input
name="password_confirmation"
:label="__('Confirm password')"
type="password"
required
autocomplete="new-password"
:placeholder="__('Confirm password')"
viewable
/>
<div class="flex items-center justify-end">
<flux:button type="submit" variant="primary" class="w-full" data-test="reset-password-button">
{{ __('Reset password') }}
</flux:button>
</div>
</form>
</div>
</x-layouts::auth>

View file

@ -1,95 +0,0 @@
<x-layouts::auth>
<div class="flex flex-col gap-6">
<div
class="relative w-full h-auto"
x-cloak
x-data="{
showRecoveryInput: @js($errors->has('recovery_code')),
code: '',
recovery_code: '',
toggleInput() {
this.showRecoveryInput = !this.showRecoveryInput;
this.code = '';
this.recovery_code = '';
$dispatch('clear-2fa-auth-code');
$nextTick(() => {
this.showRecoveryInput
? this.$refs.recovery_code?.focus()
: $dispatch('focus-2fa-auth-code');
});
},
}"
>
<div x-show="!showRecoveryInput">
<x-auth-header
:title="__('Authentication Code')"
:description="__('Enter the authentication code provided by your authenticator application.')"
/>
</div>
<div x-show="showRecoveryInput">
<x-auth-header
:title="__('Recovery Code')"
:description="__('Please confirm access to your account by entering one of your emergency recovery codes.')"
/>
</div>
<form method="POST" action="{{ route('two-factor.login.store') }}">
@csrf
<div class="space-y-5 text-center">
<div x-show="!showRecoveryInput">
<div class="flex items-center justify-center my-5">
<flux:otp
x-model="code"
length="6"
name="code"
label="OTP Code"
label:sr-only
class="mx-auto"
/>
</div>
</div>
<div x-show="showRecoveryInput">
<div class="my-5">
<flux:input
type="text"
name="recovery_code"
x-ref="recovery_code"
x-bind:required="showRecoveryInput"
autocomplete="one-time-code"
x-model="recovery_code"
/>
</div>
@error('recovery_code')
<flux:text color="red">
{{ $message }}
</flux:text>
@enderror
</div>
<flux:button
variant="primary"
type="submit"
class="w-full"
>
{{ __('Continue') }}
</flux:button>
</div>
<div class="mt-5 space-x-0.5 text-sm leading-5 text-center">
<span class="opacity-50">{{ __('or you can') }}</span>
<div class="inline font-medium underline cursor-pointer opacity-80">
<span x-show="!showRecoveryInput" @click="toggleInput()">{{ __('login using a recovery code') }}</span>
<span x-show="showRecoveryInput" @click="toggleInput()">{{ __('login using an authentication code') }}</span>
</div>
</div>
</form>
</div>
</div>
</x-layouts::auth>

View file

@ -1,29 +0,0 @@
<x-layouts::auth>
<div class="mt-4 flex flex-col gap-6">
<flux:text class="text-center">
{{ __('Please verify your email address by clicking on the link we just emailed to you.') }}
</flux:text>
@if (session('status') == 'verification-link-sent')
<flux:text class="text-center font-medium !dark:text-green-400 !text-green-600">
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
</flux:text>
@endif
<div class="flex flex-col items-center justify-between space-y-3">
<form method="POST" action="{{ route('verification.send') }}">
@csrf
<flux:button type="submit" variant="primary" class="w-full">
{{ __('Resend verification email') }}
</flux:button>
</form>
<form method="POST" action="{{ route('logout') }}">
@csrf
<flux:button variant="ghost" type="submit" class="text-sm cursor-pointer" data-test="logout-button">
{{ __('Log out') }}
</flux:button>
</form>
</div>
</div>
</x-layouts::auth>

View file

@ -1,22 +0,0 @@
<?php
use Livewire\Component;
new class extends Component
{
//
}; ?>
<section class="w-full">
@include('partials.settings-heading')
<flux:heading class="sr-only">{{ __('Appearance Settings') }}</flux:heading>
<x-pages::settings.layout :heading="__('Appearance')" :subheading="__('Update the appearance settings for your account')">
<flux:radio.group x-data variant="segmented" x-model="$flux.appearance">
<flux:radio value="light" icon="sun">{{ __('Light') }}</flux:radio>
<flux:radio value="dark" icon="moon">{{ __('Dark') }}</flux:radio>
<flux:radio value="system" icon="computer-desktop">{{ __('System') }}</flux:radio>
</flux:radio.group>
</x-pages::settings.layout>
</section>

View file

@ -1,64 +0,0 @@
<?php
use App\Concerns\PasswordValidationRules;
use App\Livewire\Actions\Logout;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
new class extends Component
{
use PasswordValidationRules;
public string $password = '';
/**
* Delete the currently authenticated user.
*/
public function deleteUser(Logout $logout): void
{
$this->validate([
'password' => $this->currentPasswordRules(),
]);
tap(Auth::user(), $logout(...))->delete();
$this->redirect('/', navigate: true);
}
}; ?>
<section class="mt-10 space-y-6">
<div class="relative mb-5">
<flux:heading>{{ __('Delete account') }}</flux:heading>
<flux:subheading>{{ __('Delete your account and all of its resources') }}</flux:subheading>
</div>
<flux:modal.trigger name="confirm-user-deletion">
<flux:button x-data="" x-on:click.prevent="$dispatch('open-modal', 'confirm-user-deletion')" data-test="delete-user-button">
{{ __('Delete account') }}
</flux:button>
</flux:modal.trigger>
<flux:modal name="confirm-user-deletion" :show="$errors->isNotEmpty()" focusable class="max-w-lg">
<form method="POST" wire:submit="deleteUser" class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Are you sure you want to delete your account?') }}</flux:heading>
<flux:subheading>
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }}
</flux:subheading>
</div>
<flux:input wire:model="password" :label="__('Password')" type="password" />
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
<flux:modal.close>
<flux:button variant="filled">{{ __('Cancel') }}</flux:button>
</flux:modal.close>
<flux:button variant="danger" type="submit" data-test="confirm-delete-user-button">
{{ __('Delete account') }}
</flux:button>
</div>
</form>
</flux:modal>
</section>

View file

@ -1,27 +0,0 @@
<div class="flex items-start max-md:flex-col">
<div class="me-10 w-full pb-4 md:w-[220px]">
<flux:navlist aria-label="{{ __('Settings') }}">
<flux:navlist.item :href="route('settings.preferences')" wire:navigate>{{ __('Preferences') }}</flux:navlist.item>
<flux:navlist.item :href="route('appearance.edit')" wire:navigate>{{ __('Appearance') }}</flux:navlist.item>
<flux:navlist.item :href="route('profile.edit')" wire:navigate>{{ __('Profile') }}</flux:navlist.item>
@if(auth()?->user()?->oidc_sub === null)
<flux:navlist.item :href="route('user-password.edit')" wire:navigate>{{ __('Password') }}</flux:navlist.item>
@endif
@if (Laravel\Fortify\Features::canManageTwoFactorAuthentication() && auth()?->user()?->oidc_sub === null)
<flux:navlist.item :href="route('two-factor.show')" wire:navigate>{{ __('2FA') }}</flux:navlist.item>
@endif
<flux:navlist.item :href="route('settings.support')" wire:navigate>{{ __('Support') }}</flux:navlist.item>
</flux:navlist>
</div>
<flux:separator class="md:hidden" />
<div class="flex-1 self-stretch max-md:pt-6">
<flux:heading>{{ $heading ?? '' }}</flux:heading>
<flux:subheading>{{ $subheading ?? '' }}</flux:subheading>
<div class="mt-5 w-full max-w-lg">
{{ $slot }}
</div>
</div>
</div>

View file

@ -1,388 +0,0 @@
<?php
use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication;
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
use Laravel\Fortify\Features;
use Laravel\Fortify\Fortify;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Symfony\Component\HttpFoundation\Response;
new class extends Component
{
#[Locked]
public bool $twoFactorEnabled;
#[Locked]
public bool $requiresConfirmation;
#[Locked]
public string $qrCodeSvg = '';
#[Locked]
public string $manualSetupKey = '';
public bool $showModal = false;
public bool $showVerificationStep = false;
#[Validate('required|string|size:6', onUpdate: false)]
public string $code = '';
/**
* Mount the component.
*/
public function mount(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void
{
abort_unless(Features::enabled(Features::twoFactorAuthentication()), Response::HTTP_FORBIDDEN);
if (Fortify::confirmsTwoFactorAuthentication() && is_null(auth()->user()->two_factor_confirmed_at)) {
$disableTwoFactorAuthentication(auth()->user());
}
$this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
$this->requiresConfirmation = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm');
}
/**
* Enable two-factor authentication for the user.
*/
public function enable(EnableTwoFactorAuthentication $enableTwoFactorAuthentication): void
{
$enableTwoFactorAuthentication(auth()->user());
if (! $this->requiresConfirmation) {
$this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
}
$this->loadSetupData();
$this->showModal = true;
}
/**
* Load the two-factor authentication setup data for the user.
*/
private function loadSetupData(): void
{
$user = auth()->user();
try {
$this->qrCodeSvg = $user?->twoFactorQrCodeSvg();
$this->manualSetupKey = decrypt($user->two_factor_secret);
} catch (Exception) {
$this->addError('setupData', 'Failed to fetch setup data.');
$this->reset('qrCodeSvg', 'manualSetupKey');
}
}
/**
* Show the two-factor verification step if necessary.
*/
public function showVerificationIfNecessary(): void
{
if ($this->requiresConfirmation) {
$this->showVerificationStep = true;
$this->resetErrorBag();
return;
}
$this->closeModal();
}
/**
* Confirm two-factor authentication for the user.
*/
public function confirmTwoFactor(ConfirmTwoFactorAuthentication $confirmTwoFactorAuthentication): void
{
$this->validate();
$confirmTwoFactorAuthentication(auth()->user(), $this->code);
$this->closeModal();
$this->twoFactorEnabled = true;
}
/**
* Reset two-factor verification state.
*/
public function resetVerification(): void
{
$this->reset('code', 'showVerificationStep');
$this->resetErrorBag();
}
/**
* Disable two-factor authentication for the user.
*/
public function disable(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void
{
$disableTwoFactorAuthentication(auth()->user());
$this->twoFactorEnabled = false;
}
/**
* Close the two-factor authentication modal.
*/
public function closeModal(): void
{
$this->reset(
'code',
'manualSetupKey',
'qrCodeSvg',
'showModal',
'showVerificationStep',
);
$this->resetErrorBag();
if (! $this->requiresConfirmation) {
$this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
}
}
/**
* Get the current modal configuration state.
*/
public function getModalConfigProperty(): array
{
if ($this->twoFactorEnabled) {
return [
'title' => __('Two-Factor Authentication Enabled'),
'description' => __('Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.'),
'buttonText' => __('Close'),
];
}
if ($this->showVerificationStep) {
return [
'title' => __('Verify Authentication Code'),
'description' => __('Enter the 6-digit code from your authenticator app.'),
'buttonText' => __('Continue'),
];
}
return [
'title' => __('Enable Two-Factor Authentication'),
'description' => __('To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app.'),
'buttonText' => __('Continue'),
];
}
} ?>
<section class="w-full">
@include('partials.settings-heading')
<flux:heading class="sr-only">{{ __('Two-Factor Authentication Settings') }}</flux:heading>
<x-pages::settings.layout
:heading="__('Two Factor Authentication')"
:subheading="__('Manage your two-factor authentication settings')"
>
<div class="flex flex-col w-full mx-auto space-y-6 text-sm" wire:cloak>
@if ($twoFactorEnabled)
<div class="space-y-4">
<div class="flex items-center gap-3">
<flux:badge color="green">{{ __('Enabled') }}</flux:badge>
</div>
<flux:text>
{{ __('With two-factor authentication enabled, you will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.') }}
</flux:text>
<livewire:pages::settings.two-factor.recovery-codes :$requiresConfirmation />
<div class="flex justify-start">
<flux:button
icon="shield-exclamation"
icon:variant="outline"
wire:click="disable"
>
{{ __('Disable 2FA') }}
</flux:button>
</div>
</div>
@else
<div class="space-y-4">
<div class="flex items-center gap-3">
<flux:badge color="red">{{ __('Disabled') }}</flux:badge>
</div>
<flux:text variant="subtle">
{{ __('When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.') }}
</flux:text>
<flux:button
icon="shield-check"
icon:variant="outline"
wire:click="enable"
>
{{ __('Enable 2FA') }}
</flux:button>
</div>
@endif
</div>
</x-pages::settings.layout>
<flux:modal
name="two-factor-setup-modal"
class="max-w-md md:min-w-md"
@close="closeModal"
wire:model="showModal"
>
<div class="space-y-6">
<div class="flex flex-col items-center space-y-4">
<div class="p-0.5 w-auto rounded-full border border-stone-100 dark:border-stone-600 bg-white dark:bg-stone-800 shadow-sm">
<div class="p-2.5 rounded-full border border-stone-200 dark:border-stone-600 overflow-hidden bg-stone-100 dark:bg-stone-200 relative">
<div class="flex items-stretch absolute inset-0 w-full h-full divide-x [&>div]:flex-1 divide-stone-200 dark:divide-stone-300 justify-around opacity-50">
@for ($i = 1; $i <= 5; $i++)
<div></div>
@endfor
</div>
<div class="flex flex-col items-stretch absolute w-full h-full divide-y [&>div]:flex-1 inset-0 divide-stone-200 dark:divide-stone-300 justify-around opacity-50">
@for ($i = 1; $i <= 5; $i++)
<div></div>
@endfor
</div>
<flux:icon.qr-code class="relative z-20 dark:text-accent-foreground"/>
</div>
</div>
<div class="space-y-2 text-center">
<flux:heading size="lg">{{ $this->modalConfig['title'] }}</flux:heading>
<flux:text>{{ $this->modalConfig['description'] }}</flux:text>
</div>
</div>
@if ($showVerificationStep)
<div class="space-y-6">
<div class="flex flex-col items-center space-y-3 justify-center">
<flux:otp
name="code"
wire:model="code"
length="6"
label="OTP Code"
label:sr-only
class="mx-auto"
/>
</div>
<div class="flex items-center space-x-3">
<flux:button
variant="outline"
class="flex-1"
wire:click="resetVerification"
>
{{ __('Back') }}
</flux:button>
<flux:button
variant="primary"
class="flex-1"
wire:click="confirmTwoFactor"
x-bind:disabled="$wire.code.length < 6"
>
{{ __('Confirm') }}
</flux:button>
</div>
</div>
@else
@error('setupData')
<flux:callout variant="danger" icon="x-circle" heading="{{ $message }}"/>
@enderror
<div class="flex justify-center">
<div class="relative w-64 overflow-hidden border rounded-lg border-stone-200 dark:border-stone-700 aspect-square">
@empty($qrCodeSvg)
<div class="absolute inset-0 flex items-center justify-center bg-white dark:bg-stone-700 animate-pulse">
<flux:icon.loading/>
</div>
@else
<div x-data class="flex items-center justify-center h-full p-4">
<div
class="bg-white p-3 rounded"
:style="($flux.appearance === 'dark' || ($flux.appearance === 'system' && $flux.dark)) ? 'filter: invert(1) brightness(1.5)' : ''"
>
{!! $qrCodeSvg !!}
</div>
</div>
@endempty
</div>
</div>
<div>
<flux:button
:disabled="$errors->has('setupData')"
variant="primary"
class="w-full"
wire:click="showVerificationIfNecessary"
>
{{ $this->modalConfig['buttonText'] }}
</flux:button>
</div>
<div class="space-y-4">
<div class="relative flex items-center justify-center w-full">
<div class="absolute inset-0 w-full h-px top-1/2 bg-stone-200 dark:bg-stone-600"></div>
<span class="relative px-2 text-sm bg-white dark:bg-stone-800 text-stone-600 dark:text-stone-400">
{{ __('or, enter the code manually') }}
</span>
</div>
<div
class="flex items-center space-x-2"
x-data="{
copied: false,
async copy() {
try {
await navigator.clipboard.writeText('{{ $manualSetupKey }}');
this.copied = true;
setTimeout(() => this.copied = false, 1500);
} catch (e) {
console.warn('Could not copy to clipboard');
}
}
}"
>
<div class="flex items-stretch w-full border rounded-xl dark:border-stone-700">
@empty($manualSetupKey)
<div class="flex items-center justify-center w-full p-3 bg-stone-100 dark:bg-stone-700">
<flux:icon.loading variant="mini"/>
</div>
@else
<input
type="text"
readonly
value="{{ $manualSetupKey }}"
class="w-full p-3 bg-transparent outline-none text-stone-900 dark:text-stone-100"
/>
<button
@click="copy()"
class="px-3 transition-colors border-l cursor-pointer border-stone-200 dark:border-stone-600"
>
<flux:icon.document-duplicate x-show="!copied" variant="outline"></flux:icon>
<flux:icon.check
x-show="copied"
variant="solid"
class="text-green-500"
></flux:icon>
</button>
@endempty
</div>
</div>
</div>
@endif
</div>
</flux:modal>
</section>

View file

@ -1,136 +0,0 @@
<?php
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
use Livewire\Attributes\Locked;
use Livewire\Component;
new class extends Component
{
#[Locked]
public array $recoveryCodes = [];
/**
* Mount the component.
*/
public function mount(): void
{
$this->loadRecoveryCodes();
}
/**
* Generate new recovery codes for the user.
*/
public function regenerateRecoveryCodes(GenerateNewRecoveryCodes $generateNewRecoveryCodes): void
{
$generateNewRecoveryCodes(auth()->user());
$this->loadRecoveryCodes();
}
/**
* Load the recovery codes for the user.
*/
private function loadRecoveryCodes(): void
{
$user = auth()->user();
if ($user->hasEnabledTwoFactorAuthentication() && $user->two_factor_recovery_codes) {
try {
$this->recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true);
} catch (Exception) {
$this->addError('recoveryCodes', 'Failed to load recovery codes');
$this->recoveryCodes = [];
}
}
}
}; ?>
<div
class="py-6 space-y-6 border shadow-sm rounded-xl border-zinc-200 dark:border-white/10"
wire:cloak
x-data="{ showRecoveryCodes: false }"
>
<div class="px-6 space-y-2">
<div class="flex items-center gap-2">
<flux:icon.lock-closed variant="outline" class="size-4"/>
<flux:heading size="lg" level="3">{{ __('2FA Recovery Codes') }}</flux:heading>
</div>
<flux:text variant="subtle">
{{ __('Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.') }}
</flux:text>
</div>
<div class="px-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<flux:button
x-show="!showRecoveryCodes"
icon="eye"
icon:variant="outline"
@click="showRecoveryCodes = true;"
aria-expanded="false"
aria-controls="recovery-codes-section"
>
{{ __('View Recovery Codes') }}
</flux:button>
<flux:button
x-show="showRecoveryCodes"
icon="eye-slash"
icon:variant="outline"
variant="primary"
@click="showRecoveryCodes = false"
aria-expanded="true"
aria-controls="recovery-codes-section"
>
{{ __('Hide Recovery Codes') }}
</flux:button>
@if (filled($recoveryCodes))
<flux:button
x-show="showRecoveryCodes"
icon="arrow-path"
variant="filled"
wire:click="regenerateRecoveryCodes"
>
{{ __('Regenerate Codes') }}
</flux:button>
@endif
</div>
<div
x-show="showRecoveryCodes"
x-transition
id="recovery-codes-section"
class="relative overflow-hidden"
x-bind:aria-hidden="!showRecoveryCodes"
>
<div class="mt-3 space-y-3">
@error('recoveryCodes')
<flux:callout variant="danger" icon="x-circle" heading="{{$message}}"/>
@enderror
@if (filled($recoveryCodes))
<div
class="grid gap-1 p-4 font-mono text-sm rounded-lg bg-zinc-100 dark:bg-white/5"
role="list"
aria-label="{{ __('Recovery codes') }}"
>
@foreach($recoveryCodes as $code)
<div
role="listitem"
class="select-text"
wire:loading.class="opacity-50 animate-pulse"
>
{{ $code }}
</div>
@endforeach
</div>
<flux:text variant="subtle" class="text-xs">
{{ __('Each recovery code can be used once to access your account and will be removed after use. If you need more, click Regenerate Codes above.') }}
</flux:text>
@endif
</div>
</div>
</div>
</div>

View file

@ -1,5 +1,5 @@
<div class="relative mb-6 w-full"> <div class="relative mb-6 w-full">
<flux:heading size="xl" level="1">{{ __('Settings') }}</flux:heading> <flux:heading size="xl" level="1">Settings</flux:heading>
<flux:subheading size="lg" class="mb-6">{{ __('Manage your profile and account settings') }}</flux:subheading> <flux:subheading size="lg" class="mb-6">Manage your profile and account settings</flux:subheading>
<flux:separator variant="subtle" /> <flux:separator variant="subtle" />
</div> </div>

View file

@ -1,4 +1,4 @@
<x-layouts::auth.card> <x-layouts.auth.card>
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<x-auth-header title="TRMNL BYOS Laravel" description="Server is up and running."/> <x-auth-header title="TRMNL BYOS Laravel" description="Server is up and running."/>
</div> </div>
@ -67,4 +67,4 @@
@endif @endif
@endif @endif
@endauth @endauth
</x-layouts::auth.card> </x-layouts.auth.card>

View file

@ -1,10 +1,25 @@
<?php <?php
use App\Http\Controllers\Auth\OidcController; use App\Http\Controllers\Auth\OidcController;
use App\Http\Controllers\Auth\VerifyEmailController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Livewire\Volt\Volt;
// Other Auth routes are handled by Fortify
Route::middleware('guest')->group(function () { Route::middleware('guest')->group(function () {
Volt::route('login', 'auth.login')
->name('login');
if (config('app.registration.enabled')) {
Volt::route('register', 'auth.register')
->name('register');
}
Volt::route('forgot-password', 'auth.forgot-password')
->name('password.request');
Volt::route('reset-password/{token}', 'auth.reset-password')
->name('password.reset');
// OIDC authentication routes // OIDC authentication routes
Route::get('auth/oidc/redirect', [OidcController::class, 'redirect']) Route::get('auth/oidc/redirect', [OidcController::class, 'redirect'])
->name('auth.oidc.redirect'); ->name('auth.oidc.redirect');
@ -13,3 +28,18 @@ Route::middleware('guest')->group(function () {
->name('auth.oidc.callback'); ->name('auth.oidc.callback');
}); });
Route::middleware('auth')->group(function () {
Volt::route('verify-email', 'auth.verify-email')
->name('verification.notice');
Route::get('verify-email/{id}/{hash}', VerifyEmailController::class)
->middleware(['signed', 'throttle:6,1'])
->name('verification.verify');
Volt::route('confirm-password', 'auth.confirm-password')
->name('password.confirm');
});
Route::post('logout', App\Livewire\Actions\Logout::class)
->name('logout');

View file

@ -1,23 +0,0 @@
<?php
use Illuminate\Support\Facades\Route;
use Laravel\Fortify\Features;
Route::middleware(['auth'])->group(function () {
Route::redirect('settings', 'settings/profile');
Route::livewire('settings/preferences', 'pages::settings.preferences')->name('settings.preferences');
Route::livewire('settings/profile', 'pages::settings.profile')->name('profile.edit');
Route::livewire('settings/password', 'pages::settings.password')->name('user-password.edit');
Route::livewire('settings/appearance', 'pages::settings.appearance')->name('appearance.edit');
Route::livewire('settings/two-factor', 'pages::settings.two-factor')
->middleware(
when(
Features::canManageTwoFactorAuthentication()
&& Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword'),
['password.confirm'],
[],
),
)
->name('two-factor.show');
Route::livewire('settings/support', 'pages::settings.support')->name('settings.support');
});

View file

@ -3,30 +3,37 @@
use App\Models\Plugin; use App\Models\Plugin;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Livewire\Volt\Volt;
Route::get('/', function () { Route::get('/', function () {
return view('welcome'); return view('welcome');
})->name('home'); })->name('home');
Route::middleware(['auth'])->group(function () { Route::middleware(['auth'])->group(function () {
Route::redirect('settings', 'settings/preferences');
Volt::route('settings/preferences', 'settings.preferences')->name('settings.preferences');
Volt::route('settings/profile', 'settings.profile')->name('settings.profile');
Volt::route('settings/password', 'settings.password')->name('settings.password');
Volt::route('settings/appearance', 'settings.appearance')->name('settings.appearance');
Volt::route('settings/support', 'settings.support')->name('settings.support');
Route::livewire('/dashboard', 'device-dashboard')->name('dashboard'); Volt::route('/dashboard', 'device-dashboard')->name('dashboard');
Route::livewire('/devices', 'devices.manage')->name('devices'); Volt::route('/devices', 'devices.manage')->name('devices');
Route::livewire('/devices/{device}/configure', 'devices.configure')->name('devices.configure'); Volt::route('/devices/{device}/configure', 'devices.configure')->name('devices.configure');
Route::livewire('/devices/{device}/logs', 'devices.logs')->name('devices.logs'); Volt::route('/devices/{device}/logs', 'devices.logs')->name('devices.logs');
Route::livewire('/device-models', 'device-models.index')->name('device-models.index'); Volt::route('/device-models', 'device-models.index')->name('device-models.index');
Route::livewire('/device-palettes', 'device-palettes.index')->name('device-palettes.index'); Volt::route('/device-palettes', 'device-palettes.index')->name('device-palettes.index');
Route::livewire('plugins', 'plugins.index')->name('plugins.index'); Volt::route('plugins', 'plugins.index')->name('plugins.index');
Route::livewire('plugins/recipe/{plugin}', 'plugins.recipe')->name('plugins.recipe'); Volt::route('plugins/recipe/{plugin}', 'plugins.recipe')->name('plugins.recipe');
Route::livewire('plugins/markup', 'plugins.markup')->name('plugins.markup'); Volt::route('plugins/markup', 'plugins.markup')->name('plugins.markup');
Route::livewire('plugins/api', 'plugins.api')->name('plugins.api'); Volt::route('plugins/api', 'plugins.api')->name('plugins.api');
Route::livewire('plugins/image-webhook', 'plugins.image-webhook')->name('plugins.image-webhook'); Volt::route('plugins/image-webhook', 'plugins.image-webhook')->name('plugins.image-webhook');
Route::livewire('plugins/image-webhook/{plugin}', 'plugins.image-webhook-instance')->name('plugins.image-webhook-instance'); Volt::route('plugins/image-webhook/{plugin}', 'plugins.image-webhook-instance')->name('plugins.image-webhook-instance');
Route::livewire('playlists', 'playlists.index')->name('playlists.index'); Volt::route('playlists', 'playlists.index')->name('playlists.index');
Route::get('plugin_settings/{trmnlp_id}/edit', function (Request $request, string $trmnlp_id) { Route::get('plugin_settings/{trmnlp_id}/edit', function (Request $request, string $trmnlp_id) {
$plugin = Plugin::query() $plugin = Plugin::query()
@ -38,4 +45,3 @@ Route::middleware(['auth'])->group(function () {
}); });
require __DIR__.'/auth.php'; require __DIR__.'/auth.php';
require __DIR__.'/settings.php';

View file

@ -1,6 +1,7 @@
<?php <?php
use App\Models\User; use App\Models\User;
use Livewire\Volt\Volt as LivewireVolt;
uses(Illuminate\Foundation\Testing\RefreshDatabase::class); uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
@ -13,13 +14,13 @@ test('login screen can be rendered', function (): void {
test('users can authenticate using the login screen', function (): void { test('users can authenticate using the login screen', function (): void {
$user = User::factory()->create(); $user = User::factory()->create();
$response = $this->post(route('login.store'), [ $response = LivewireVolt::test('auth.login')
'email' => $user->email, ->set('email', $user->email)
'password' => 'password', ->set('password', 'password')
]); ->call('login');
$response $response
->assertSessionHasNoErrors() ->assertHasNoErrors()
->assertRedirect(route('dashboard', absolute: false)); ->assertRedirect(route('dashboard', absolute: false));
$this->assertAuthenticated(); $this->assertAuthenticated();

View file

@ -10,9 +10,9 @@ uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
test('email verification screen can be rendered', function (): void { test('email verification screen can be rendered', function (): void {
$user = User::factory()->unverified()->create(); $user = User::factory()->unverified()->create();
$response = $this->actingAs($user)->get(route('verification.notice')); $response = $this->actingAs($user)->get('/verify-email');
$response->assertOk(); $response->assertStatus(200);
}); });
test('email can be verified', function (): void { test('email can be verified', function (): void {

View file

@ -1,13 +1,40 @@
<?php <?php
use App\Models\User; use App\Models\User;
use Livewire\Volt\Volt;
uses(Illuminate\Foundation\Testing\RefreshDatabase::class); uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
test('confirm password screen can be rendered', function (): void { test('confirm password screen can be rendered', function (): void {
$user = User::factory()->create(); $user = User::factory()->create();
$response = $this->actingAs($user)->get(route('password.confirm')); $response = $this->actingAs($user)->get('/confirm-password');
$response->assertOk(); $response->assertStatus(200);
});
test('password can be confirmed', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$response = Volt::test('auth.confirm-password')
->set('password', 'password')
->call('confirmPassword');
$response
->assertHasNoErrors()
->assertRedirect(route('dashboard', absolute: false));
});
test('password is not confirmed with invalid password', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$response = Volt::test('auth.confirm-password')
->set('password', 'wrong-password')
->call('confirmPassword');
$response->assertHasErrors(['password']);
}); });

View file

@ -3,6 +3,7 @@
use App\Models\User; use App\Models\User;
use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Notification;
use Livewire\Volt\Volt;
uses(Illuminate\Foundation\Testing\RefreshDatabase::class); uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
@ -17,7 +18,9 @@ test('reset password link can be requested', function (): void {
$user = User::factory()->create(); $user = User::factory()->create();
$this->post(route('password.request'), ['email' => $user->email]); Volt::test('auth.forgot-password')
->set('email', $user->email)
->call('sendPasswordResetLink');
Notification::assertSentTo($user, ResetPassword::class); Notification::assertSentTo($user, ResetPassword::class);
}); });
@ -27,12 +30,14 @@ test('reset password screen can be rendered', function (): void {
$user = User::factory()->create(); $user = User::factory()->create();
$this->post(route('password.request'), ['email' => $user->email]); Volt::test('auth.forgot-password')
->set('email', $user->email)
->call('sendPasswordResetLink');
Notification::assertSentTo($user, ResetPassword::class, function ($notification): true { Notification::assertSentTo($user, ResetPassword::class, function ($notification): true {
$response = $this->get(route('password.reset', $notification->token)); $response = $this->get('/reset-password/'.$notification->token);
$response->assertOk(); $response->assertStatus(200);
return true; return true;
}); });
@ -43,18 +48,19 @@ test('password can be reset with valid token', function (): void {
$user = User::factory()->create(); $user = User::factory()->create();
$this->post(route('password.request'), ['email' => $user->email]); Volt::test('auth.forgot-password')
->set('email', $user->email)
->call('sendPasswordResetLink');
Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user): true { Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user): true {
$response = $this->post(route('password.update'), [ $response = Volt::test('auth.reset-password', ['token' => $notification->token])
'token' => $notification->token, ->set('email', $user->email)
'email' => $user->email, ->set('password', 'password')
'password' => 'password', ->set('password_confirmation', 'password')
'password_confirmation' => 'password', ->call('resetPassword');
]);
$response $response
->assertSessionHasNoErrors() ->assertHasNoErrors()
->assertRedirect(route('login', absolute: false)); ->assertRedirect(route('login', absolute: false));
return true; return true;

View file

@ -1,22 +1,25 @@
<?php <?php
use Livewire\Volt\Volt;
uses(Illuminate\Foundation\Testing\RefreshDatabase::class); uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
test('registration screen can be rendered', function (): void { test('registration screen can be rendered', function (): void {
$response = $this->get(route('register')); $response = $this->get('/register');
$response->assertOk(); $response->assertStatus(200);
}); });
test('new users can register', function (): void { test('new users can register', function (): void {
$response = $this->post(route('register.store'), [ $response = Volt::test('auth.register')
'name' => 'John Doe', ->set('name', 'Test User')
'email' => 'test@example.com', ->set('email', 'test@example.com')
'password' => 'password', ->set('password', 'password')
'password_confirmation' => 'password', ->set('password_confirmation', 'password')
]); ->call('register');
$response->assertSessionHasNoErrors() $response
->assertHasNoErrors()
->assertRedirect(route('dashboard', absolute: false)); ->assertRedirect(route('dashboard', absolute: false));
$this->assertAuthenticated(); $this->assertAuthenticated();

View file

@ -1,34 +0,0 @@
<?php
use App\Models\User;
use Laravel\Fortify\Features;
uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
test('two_factor_challenge_redirects_to_login_when_not_authenticated', function (): void {
if (! Features::canManageTwoFactorAuthentication()) {
$this->markTestSkipped('Two-factor authentication is not enabled.');
}
$response = $this->get(route('two-factor.login'));
$response->assertRedirect(route('login'));
});
test('two_factor_challenge_can_be_rendered', function (): void {
if (! Features::canManageTwoFactorAuthentication()) {
$this->markTestSkipped('Two-factor authentication is not enabled.');
}
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
]);
$user = User::factory()->withTwoFactor()->create();
$this->post(route('login.store'), [
'email' => $user->email,
'password' => 'password',
])->assertRedirect(route('two-factor.login'));
});

View file

@ -2,6 +2,7 @@
use App\Models\Device; use App\Models\Device;
use App\Models\User; use App\Models\User;
use Livewire\Volt\Volt;
uses(Illuminate\Foundation\Testing\RefreshDatabase::class); uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
@ -26,7 +27,7 @@ test('user can create a new device', function (): void {
'friendly_id' => 'test-device-1', 'friendly_id' => 'test-device-1',
]; ];
$response = Livewire::test('devices.manage') $response = Volt::test('devices.manage')
->set('name', $deviceData['name']) ->set('name', $deviceData['name'])
->set('mac_address', $deviceData['mac_address']) ->set('mac_address', $deviceData['mac_address'])
->set('api_key', $deviceData['api_key']) ->set('api_key', $deviceData['api_key'])
@ -51,7 +52,7 @@ test('device creation requires required fields', function (): void {
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$response = Livewire::test('devices.manage') $response = Volt::test('devices.manage')
->set('name', '') ->set('name', '')
->set('mac_address', '') ->set('mac_address', '')
->set('api_key', '') ->set('api_key', '')
@ -74,14 +75,14 @@ test('user can toggle proxy cloud for their device', function (): void {
'proxy_cloud' => false, 'proxy_cloud' => false,
]); ]);
$response = Livewire::test('devices.manage') $response = Volt::test('devices.manage')
->call('toggleProxyCloud', $device); ->call('toggleProxyCloud', $device);
$response->assertHasNoErrors(); $response->assertHasNoErrors();
expect($device->fresh()->proxy_cloud)->toBeTrue(); expect($device->fresh()->proxy_cloud)->toBeTrue();
// Toggle back to false // Toggle back to false
$response = Livewire::test('devices.manage') $response = Volt::test('devices.manage')
->call('toggleProxyCloud', $device); ->call('toggleProxyCloud', $device);
expect($device->fresh()->proxy_cloud)->toBeFalse(); expect($device->fresh()->proxy_cloud)->toBeFalse();
@ -97,7 +98,7 @@ test('user cannot toggle proxy cloud for other users devices', function (): void
'proxy_cloud' => false, 'proxy_cloud' => false,
]); ]);
$response = Livewire::test('devices.manage') $response = Volt::test('devices.manage')
->call('toggleProxyCloud', $device); ->call('toggleProxyCloud', $device);
$response->assertStatus(403); $response->assertStatus(403);

Some files were not shown because too many files have changed in this diff Show more