refactor: implement 2FA stubs and remove dead BoostPurchase component

Replace the five 2FA stub methods in Settings with real implementations
that delegate to the TwoFactorAuthenticatable trait from php-tenant.
The 2FA tab now auto-enables when the User model uses the trait, and
handles the full lifecycle: enable, verify, confirm, view/regenerate
recovery codes, and disable.

Remove the orphaned BoostPurchase Livewire component and its blade
template. The /boosts route already redirects to the account usage
page's boosts tab, making this component dead code.

Update language strings: remove the old "upgrading" stub message and
add proper 2FA success/error messages.

Fixes #15
Fixes #16

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude 2026-03-24 16:44:20 +00:00
parent f90cd2c3ec
commit b1abc8d639
No known key found for this signature in database
GPG key ID: AF404715446AEB41
4 changed files with 109 additions and 190 deletions

View file

@ -652,7 +652,11 @@ return [
'profile_updated' => 'Profile updated successfully.',
'preferences_updated' => 'Preferences saved.',
'password_updated' => 'Password changed successfully.',
'two_factor_upgrading' => 'Two-factor authentication is currently being upgraded. Please try again later.',
'two_factor_enabled' => 'Two-factor authentication has been enabled.',
'two_factor_disabled' => 'Two-factor authentication has been disabled.',
'two_factor_invalid_code' => 'The verification code is invalid.',
'two_factor_codes_regenerated' => 'Recovery codes have been regenerated.',
'two_factor_unavailable' => 'Two-factor authentication is not available for your account.',
'deletion_scheduled' => 'Account deletion scheduled. Check your email for options.',
'deletion_cancelled' => 'Account deletion has been cancelled.',
],

View file

@ -1,90 +0,0 @@
<div>
<!-- Page header -->
<div class="mb-8">
<h1 class="text-2xl md:text-3xl text-gray-800 dark:text-gray-100 font-bold">{{ __('hub::hub.boosts.title') }}</h1>
<p class="text-gray-500 dark:text-gray-400 mt-1">{{ __('hub::hub.boosts.subtitle') }}</p>
</div>
<div class="space-y-6">
@if(count($boostOptions) > 0)
<div class="grid gap-4 sm:grid-cols-2">
@foreach($boostOptions as $boost)
<div class="bg-white dark:bg-gray-800 shadow-xs rounded-xl p-6">
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100">
{{ $boost['feature_name'] }}
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
{{ $boost['description'] }}
</p>
</div>
@switch($boost['boost_type'])
@case('add_limit')
<core:badge color="blue">+{{ number_format($boost['limit_value']) }}</core:badge>
@break
@case('unlimited')
<core:badge color="purple">{{ __('hub::hub.boosts.types.unlimited') }}</core:badge>
@break
@case('enable')
<core:badge color="green">{{ __('hub::hub.boosts.types.enable') }}</core:badge>
@break
@endswitch
</div>
<div class="flex items-center justify-between pt-4 border-t border-gray-100 dark:border-gray-700/60">
<div class="text-sm text-gray-500 dark:text-gray-400">
@switch($boost['duration_type'])
@case('cycle_bound')
<core:icon name="clock" class="size-4 mr-1" />
{{ __('hub::hub.boosts.duration.cycle_bound') }}
@break
@case('duration')
<core:icon name="calendar" class="size-4 mr-1" />
{{ __('hub::hub.boosts.duration.limited') }}
@break
@case('permanent')
<core:icon name="infinity" class="size-4 mr-1" />
{{ __('hub::hub.boosts.duration.permanent') }}
@break
@endswitch
</div>
<core:button wire:click="purchaseBoost('{{ $boost['blesta_id'] }}')" size="sm" variant="primary">
{{ __('hub::hub.boosts.actions.purchase') }}
</core:button>
</div>
</div>
@endforeach
</div>
@else
<div class="bg-white dark:bg-gray-800 shadow-xs rounded-xl">
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
<core:icon name="rocket" class="size-8 mx-auto mb-2 opacity-50" />
<p>{{ __('hub::hub.boosts.empty.title') }}</p>
<p class="text-sm mt-1">{{ __('hub::hub.boosts.empty.hint') }}</p>
</div>
</div>
@endif
<!-- Info Section -->
<div class="bg-blue-500/10 dark:bg-blue-500/20 rounded-xl p-6">
<h3 class="font-semibold text-blue-900 dark:text-blue-100 mb-2">
<core:icon name="circle-info" class="size-5 mr-2" />
{{ __('hub::hub.boosts.info.title') }}
</h3>
<ul class="text-sm text-blue-800 dark:text-blue-200 space-y-2 ml-7">
<li><strong>{{ __('hub::hub.boosts.labels.cycle_bound') }}</strong> {{ __('hub::hub.boosts.info.cycle_bound') }}</li>
<li><strong>{{ __('hub::hub.boosts.labels.duration_based') }}</strong> {{ __('hub::hub.boosts.info.duration_based') }}</li>
<li><strong>{{ __('hub::hub.boosts.labels.permanent') }}</strong> {{ __('hub::hub.boosts.info.permanent') }}</li>
</ul>
</div>
<!-- Back Link -->
<div class="flex justify-start">
<core:button href="{{ route('hub.usage') }}" variant="ghost">
<core:icon name="arrow-left" class="mr-2" />
{{ __('hub::hub.boosts.actions.back') }}
</core:button>
</div>
</div>
</div>

View file

@ -1,79 +0,0 @@
<?php
declare(strict_types=1);
namespace Website\Hub\View\Modal\Admin;
use Core\Tenant\Models\Feature;
use Livewire\Component;
class BoostPurchase extends Component
{
/**
* Available boost options from config.
*/
public array $boostOptions = [];
public function mount(): void
{
// Require authenticated user with a workspace
if (! auth()->check()) {
abort(403, 'Authentication required.');
}
// Get boost options from config
$addonMapping = config('services.blesta.addon_mapping', []);
$this->boostOptions = collect($addonMapping)->map(function ($config, $blestaId) {
$feature = Feature::where('code', $config['feature_code'])->first();
return [
'blesta_id' => $blestaId,
'feature_code' => $config['feature_code'],
'feature_name' => $feature?->name ?? $config['feature_code'],
'boost_type' => $config['boost_type'],
'limit_value' => $config['limit_value'] ?? null,
'duration_type' => $config['duration_type'],
'description' => $this->getBoostDescription($config),
];
})->values()->toArray();
}
protected function getBoostDescription(array $config): string
{
$type = $config['boost_type'];
$value = $config['limit_value'] ?? null;
$duration = $config['duration_type'];
$description = match ($type) {
'add_limit' => "+{$value} additional",
'unlimited' => 'Unlimited access',
'enable' => 'Feature enabled',
default => 'Boost',
};
$durationText = match ($duration) {
'cycle_bound' => 'until billing cycle ends',
'duration' => 'for limited time',
'permanent' => 'permanently',
default => '',
};
return trim("{$description} {$durationText}");
}
public function purchaseBoost(string $blestaId): void
{
// Redirect to Blesta for purchase
// TODO: Implement when Blesta is configured
$blestaUrl = config('services.blesta.url', 'https://billing.host.uk.com');
$this->redirect("{$blestaUrl}/order/addon/{$blestaId}");
}
public function render()
{
return view('hub::admin.boost-purchase')
->layout('hub::admin.layouts.app', ['title' => 'Purchase Boost']);
}
}

View file

@ -4,6 +4,12 @@ declare(strict_types=1);
namespace Website\Hub\View\Modal\Admin;
use Core\Mod\Social\Models\Setting;
use Core\Tenant\Concerns\TwoFactorAuthenticatable;
use Core\Tenant\Mail\AccountDeletionRequested;
use Core\Tenant\Models\AccountDeletionRequest;
use Core\Tenant\Models\User;
use Core\Tenant\Services\UserStatsService;
use Flux\Flux;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
@ -12,11 +18,6 @@ use Illuminate\Validation\Rules\Password;
use Livewire\Attributes\Url;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Core\Mod\Social\Models\Setting;
use Core\Tenant\Mail\AccountDeletionRequested;
use Core\Tenant\Models\AccountDeletionRequest;
use Core\Tenant\Models\User;
use Core\Tenant\Services\UserStatsService;
use Website\Hub\Concerns\HasRateLimiting;
class Settings extends Component
@ -96,11 +97,9 @@ class Settings extends Component
$this->time_format = (int) $this->getUserSetting('time_format', 12);
$this->week_starts_on = (int) $this->getUserSetting('week_starts_on', 1);
// Feature flags - 2FA disabled until native implementation
$this->isTwoFactorEnabled = config('social.features.two_factor_auth', false);
$this->userHasTwoFactorEnabled = method_exists($user, 'hasTwoFactorAuthEnabled')
? $user->hasTwoFactorAuthEnabled()
: false;
// Feature flags - 2FA enabled when the User model uses TwoFactorAuthenticatable
$this->isTwoFactorEnabled = $this->userSupportsTwoFactor($user);
$this->userHasTwoFactorEnabled = $this->isTwoFactorEnabled && $user->hasTwoFactorAuthEnabled();
// Check for pending deletion request
$this->pendingDeletion = AccountDeletionRequest::where('user_id', $user->id)
@ -113,6 +112,14 @@ class Settings extends Component
$this->timezones = UserStatsService::getTimezoneList();
}
/**
* Check whether the User model uses the TwoFactorAuthenticatable trait.
*/
protected function userSupportsTwoFactor(User $user): bool
{
return in_array(TwoFactorAuthenticatable::class, class_uses_recursive($user), true);
}
protected function onRateLimited(string $action, string $key): void
{
$seconds = \Illuminate\Support\Facades\RateLimiter::availableIn($key);
@ -196,34 +203,111 @@ class Settings extends Component
});
}
/**
* Begin 2FA setup: generate a secret and display the QR code.
*/
public function enableTwoFactor(): void
{
// TODO: Implement native 2FA - currently disabled
Flux::toast(text: __('hub::hub.settings.messages.two_factor_upgrading'), variant: 'warning');
$user = User::findOrFail(Auth::id());
if (! $this->userSupportsTwoFactor($user)) {
Flux::toast(text: __('hub::hub.settings.messages.two_factor_unavailable'), variant: 'warning');
return;
}
$secret = $user->enableTwoFactorAuth();
$this->twoFactorSecretKey = $secret;
$this->twoFactorQrCode = $user->twoFactorQrCodeSvg();
$this->showTwoFactorSetup = true;
$this->twoFactorCode = '';
}
/**
* Verify the TOTP code and confirm 2FA activation.
*/
public function confirmTwoFactor(): void
{
// TODO: Implement native 2FA - currently disabled
Flux::toast(text: __('hub::hub.settings.messages.two_factor_upgrading'), variant: 'warning');
$this->validate([
'twoFactorCode' => ['required', 'string', 'size:6'],
]);
$user = User::findOrFail(Auth::id());
if (! $this->userSupportsTwoFactor($user)) {
Flux::toast(text: __('hub::hub.settings.messages.two_factor_unavailable'), variant: 'warning');
return;
}
if (! $user->verifyTwoFactorCode($this->twoFactorCode)) {
$this->addError('twoFactorCode', __('hub::hub.settings.messages.two_factor_invalid_code'));
return;
}
$this->recoveryCodes = $user->confirmTwoFactorAuth();
$this->showTwoFactorSetup = false;
$this->showRecoveryCodes = true;
$this->userHasTwoFactorEnabled = true;
$this->twoFactorCode = '';
$this->twoFactorQrCode = null;
$this->twoFactorSecretKey = null;
Flux::toast(text: __('hub::hub.settings.messages.two_factor_enabled'), variant: 'success');
}
/**
* Display the user's existing recovery codes.
*/
public function showRecoveryCodesModal(): void
{
// TODO: Implement native 2FA - currently disabled
Flux::toast(text: __('hub::hub.settings.messages.two_factor_upgrading'), variant: 'warning');
$user = User::findOrFail(Auth::id());
if (! $this->userSupportsTwoFactor($user) || ! $user->hasTwoFactorAuthEnabled()) {
return;
}
$this->recoveryCodes = $user->twoFactorRecoveryCodes();
$this->showRecoveryCodes = true;
}
/**
* Generate a fresh set of recovery codes.
*/
public function regenerateRecoveryCodes(): void
{
// TODO: Implement native 2FA - currently disabled
Flux::toast(text: __('hub::hub.settings.messages.two_factor_upgrading'), variant: 'warning');
$user = User::findOrFail(Auth::id());
if (! $this->userSupportsTwoFactor($user) || ! $user->hasTwoFactorAuthEnabled()) {
return;
}
$this->recoveryCodes = $user->regenerateTwoFactorRecoveryCodes();
$this->showRecoveryCodes = true;
Flux::toast(text: __('hub::hub.settings.messages.two_factor_codes_regenerated'), variant: 'success');
}
/**
* Disable 2FA for the current user.
*/
public function disableTwoFactor(): void
{
// TODO: Implement native 2FA - currently disabled
Flux::toast(text: __('hub::hub.settings.messages.two_factor_upgrading'), variant: 'warning');
$user = User::findOrFail(Auth::id());
if (! $this->userSupportsTwoFactor($user)) {
return;
}
$user->disableTwoFactorAuth();
$this->userHasTwoFactorEnabled = false;
$this->showRecoveryCodes = false;
$this->recoveryCodes = [];
Flux::toast(text: __('hub::hub.settings.messages.two_factor_disabled'), variant: 'success');
}
public function requestAccountDeletion(): void