From e8401981ed26df5ca186e5dd2865c138838d2d3a Mon Sep 17 00:00:00 2001 From: DariusIII Date: Tue, 10 Mar 2026 17:09:31 +0100 Subject: [PATCH] Add admin page to handle site status and registrations --- .../Admin/AdminRegistrationController.php | 187 ++++++++ app/Http/Controllers/Api/ApiController.php | 14 +- app/Http/Controllers/Api/ApiV2Controller.php | 14 +- .../Controllers/Auth/RegisterController.php | 180 ++++---- app/Http/Controllers/InvitationController.php | 41 +- .../Admin/RegistrationPeriodRequest.php | 76 ++++ .../Admin/UpdateRegistrationStatusRequest.php | 35 ++ app/Models/RegistrationPeriod.php | 86 ++++ app/Models/RegistrationStatusHistory.php | 80 ++++ app/Models/Settings.php | 14 + .../RegistrationFailureLogService.php | 139 ++++++ app/Services/RegistrationStatusService.php | 280 ++++++++++++ ...0000_create_registration_periods_table.php | 34 ++ ...eate_registration_status_history_table.php | 35 ++ .../views/admin/registrations/index.blade.php | 378 +++++++++++++++ resources/views/admin/site/edit.blade.php | 21 +- resources/views/auth/register.blade.php | 13 +- resources/views/invitations/show.blade.php | 16 +- resources/views/partials/admin-menu.blade.php | 3 + routes/web.php | 7 + .../AdminRegistrationControllerTest.php | 429 ++++++++++++++++++ tests/Feature/RegisterControllerTest.php | 182 +++++++- 22 files changed, 2123 insertions(+), 141 deletions(-) create mode 100644 app/Http/Controllers/Admin/AdminRegistrationController.php create mode 100644 app/Http/Requests/Admin/RegistrationPeriodRequest.php create mode 100644 app/Http/Requests/Admin/UpdateRegistrationStatusRequest.php create mode 100644 app/Models/RegistrationPeriod.php create mode 100644 app/Models/RegistrationStatusHistory.php create mode 100644 app/Services/RegistrationFailureLogService.php create mode 100644 app/Services/RegistrationStatusService.php create mode 100644 database/migrations/2026_03_10_000000_create_registration_periods_table.php create mode 100644 database/migrations/2026_03_10_000001_create_registration_status_history_table.php create mode 100644 resources/views/admin/registrations/index.blade.php create mode 100644 tests/Feature/AdminRegistrationControllerTest.php diff --git a/app/Http/Controllers/Admin/AdminRegistrationController.php b/app/Http/Controllers/Admin/AdminRegistrationController.php new file mode 100644 index 000000000..5452a6d56 --- /dev/null +++ b/app/Http/Controllers/Admin/AdminRegistrationController.php @@ -0,0 +1,187 @@ +setAdminPrefs(); + + $editingPeriod = null; + if ($request->filled('edit_period')) { + $editingPeriod = RegistrationPeriod::query() + ->with(['createdByUser', 'updatedByUser']) + ->find((int) $request->integer('edit_period')); + + if ($editingPeriod === null) { + return redirect() + ->route('admin.registrations.index') + ->with('error', 'The selected registration period could not be found.'); + } + } + + $status = $this->registrationStatusService->resolve(); + $periods = RegistrationPeriod::query() + ->with(['createdByUser', 'updatedByUser']) + ->orderByDesc('starts_at') + ->get(); + $history = RegistrationStatusHistory::query() + ->with(['changedByUser', 'registrationPeriod']) + ->orderByDesc('created_at') + ->limit(20) + ->get(); + + $recentSuccessfulRegistrations = UserActivity::query() + ->where('activity_type', 'registered') + ->latest('created_at') + ->limit(30) + ->get() + ->filter(function (UserActivity $activity): bool { + return is_array($activity->metadata) + && isset($activity->metadata['email'], $activity->metadata['ip_address']); + }) + ->take(10) + ->values(); + + $recentFailedAttempts = collect($this->registrationFailureLogService->recentFailures(10)) + ->map(function (array $entry): array { + $entry['registration_status_label'] = is_numeric($entry['registration_status']) + ? $this->registrationStatusService->statusLabel((int) $entry['registration_status']) + : null; + $entry['manual_registration_status_label'] = is_numeric($entry['manual_registration_status']) + ? $this->registrationStatusService->statusLabel((int) $entry['manual_registration_status']) + : null; + + return $entry; + }) + ->all(); + + return view('admin.registrations.index', array_merge($this->viewData, [ + 'title' => 'Registration Admin', + 'page_title' => 'Registration Admin', + 'meta_title' => 'Registration Admin', + 'meta_description' => 'Manage registration status, scheduled open periods, and registration activity.', + 'registrationStatus' => $status, + 'statusOptions' => $this->registrationStatusService->statusOptions(), + 'periods' => $periods, + 'editingPeriod' => $editingPeriod, + 'history' => $history, + 'recentSuccessfulRegistrations' => $recentSuccessfulRegistrations, + 'recentFailedAttempts' => $recentFailedAttempts, + ])); + } + + public function updateStatus(UpdateRegistrationStatusRequest $request): RedirectResponse + { + /** @var User|null $admin */ + $admin = Auth::user(); + + $newStatus = (int) $request->integer('registerstatus'); + $oldStatus = $this->registrationStatusService->getManualStatus(); + + $this->registrationStatusService->updateManualStatus( + $newStatus, + $admin, + $request->input('note') + ); + + $message = $oldStatus === $newStatus + ? 'Registration status note saved without changing the manual mode.' + : 'Manual registration status updated successfully.'; + + return redirect() + ->route('admin.registrations.index') + ->with('success', $message); + } + + public function storePeriod(RegistrationPeriodRequest $request): RedirectResponse + { + /** @var User|null $admin */ + $admin = Auth::user(); + + $this->registrationStatusService->createPeriod( + array_merge($request->validated(), [ + 'is_enabled' => $request->boolean('is_enabled'), + ]), + $admin + ); + + return redirect() + ->route('admin.registrations.index') + ->with('success', 'Scheduled open-registration period created successfully.'); + } + + public function updatePeriod(RegistrationPeriodRequest $request, RegistrationPeriod $period): RedirectResponse + { + /** @var User|null $admin */ + $admin = Auth::user(); + + $this->registrationStatusService->updatePeriod( + $period, + array_merge($request->validated(), [ + 'is_enabled' => $request->boolean('is_enabled'), + ]), + $admin + ); + + return redirect() + ->route('admin.registrations.index') + ->with('success', 'Scheduled open-registration period updated successfully.'); + } + + public function togglePeriod(Request $request, RegistrationPeriod $period): RedirectResponse + { + /** @var User|null $admin */ + $admin = Auth::user(); + + $this->registrationStatusService->togglePeriod( + $period, + $admin, + $request->input('note') + ); + + return redirect() + ->route('admin.registrations.index') + ->with('success', 'Scheduled open-registration period status updated successfully.'); + } + + public function destroyPeriod(Request $request, RegistrationPeriod $period): RedirectResponse + { + /** @var User|null $admin */ + $admin = Auth::user(); + + $this->registrationStatusService->deletePeriod( + $period, + $admin, + $request->input('note') + ); + + return redirect() + ->route('admin.registrations.index') + ->with('success', 'Scheduled open-registration period deleted successfully.'); + } +} diff --git a/app/Http/Controllers/Api/ApiController.php b/app/Http/Controllers/Api/ApiController.php index 9279594d9..a8cb49f20 100644 --- a/app/Http/Controllers/Api/ApiController.php +++ b/app/Http/Controllers/Api/ApiController.php @@ -13,6 +13,7 @@ use App\Models\Settings; use App\Models\UsenetGroup; use App\Models\User; use App\Models\UserRequest; +use App\Services\RegistrationStatusService; use App\Services\Releases\ReleaseBrowseService; use App\Services\Releases\ReleaseSearchService; use Illuminate\Contracts\Foundation\Application; @@ -465,10 +466,6 @@ class ApiController extends BasePageController 'max' => 100, 'default' => 100, ], - 'registration' => [ - 'available' => 'yes', - 'open' => (int) Settings::settingValue('registerstatus') === 0 ? 'yes' : 'no', - ], 'searching' => [ 'search' => ['available' => 'yes', 'supportedParams' => 'q'], 'tv-search' => ['available' => 'yes', 'supportedParams' => 'q,vid,tvdbid,traktid,rid,tvmazeid,imdbid,tmdbid,season,ep'], @@ -478,6 +475,12 @@ class ApiController extends BasePageController ]; }); + $registrationStatus = app(RegistrationStatusService::class)->resolve(); + $serverInfo['registration'] = [ + 'available' => $registrationStatus['available'] ? 'yes' : 'no', + 'open' => $registrationStatus['is_open'] ? 'yes' : 'no', + ]; + // Only load categories for caps requests (also cached via Category::getForMenu) $serverInfo['categories'] = $includeCats ? Category::getForMenu() : null; @@ -526,9 +529,6 @@ class ApiController extends BasePageController /** * Verify groupName parameter. * - * - * @return list - * * @throws \Exception */ public function group(Request $request): string|int|bool diff --git a/app/Http/Controllers/Api/ApiV2Controller.php b/app/Http/Controllers/Api/ApiV2Controller.php index 3086abbb8..ebc80a429 100644 --- a/app/Http/Controllers/Api/ApiV2Controller.php +++ b/app/Http/Controllers/Api/ApiV2Controller.php @@ -11,6 +11,7 @@ use App\Models\Release; use App\Models\Settings; use App\Models\User; use App\Models\UserRequest; +use App\Services\RegistrationStatusService; use App\Services\Releases\ReleaseBrowseService; use App\Services\Releases\ReleaseSearchService; use App\Transformers\ApiTransformer; @@ -98,10 +99,6 @@ class ApiV2Controller extends BasePageController 'max' => 100, 'default' => 100, ], - 'registration' => [ - 'available' => 'no', - 'open' => (int) Settings::settingValue('registerstatus') === 0 ? 'yes' : 'no', - ], 'searching' => [ 'search' => ['available' => 'yes', 'supportedParams' => 'id'], 'tv-search' => ['available' => 'yes', 'supportedParams' => 'id,vid,tvdbid,traktid,rid,tvmazeid,imdbid,tmdbid,season,ep'], @@ -112,6 +109,12 @@ class ApiV2Controller extends BasePageController ]; }); + $registrationStatus = app(RegistrationStatusService::class)->resolve(); + $capabilities['registration'] = [ + 'available' => $registrationStatus['available'] ? 'yes' : 'no', + 'open' => $registrationStatus['is_open'] ? 'yes' : 'no', + ]; + return response()->json($capabilities); } @@ -193,6 +196,9 @@ class ApiV2Controller extends BasePageController $minSize = $request->has('minsize') && $request->input('minsize') > 0 ? $request->input('minsize') : 0; $maxAge = $this->api->maxAge($request); $groupName = $this->api->group($request); + if (is_array($groupName)) { + $groupName = $groupName[0] ?? -1; + } $categoryID = $this->api->categoryID($request); $limit = $this->api->limit($request); diff --git a/app/Http/Controllers/Auth/RegisterController.php b/app/Http/Controllers/Auth/RegisterController.php index 283c4c9d9..b592ce884 100644 --- a/app/Http/Controllers/Auth/RegisterController.php +++ b/app/Http/Controllers/Auth/RegisterController.php @@ -7,9 +7,9 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; use App\Http\Requests\Auth\RegisterRegisterRequest; use App\Models\Invitation; -use App\Models\Settings; use App\Models\User; use App\Rules\ValidEmailDomain; +use App\Services\RegistrationStatusService; use App\Support\Auth\RegistersUsers; use App\Support\PermissionSyncHelper; use Illuminate\Http\RedirectResponse; @@ -49,13 +49,9 @@ class RegisterController extends Controller private string $inviteCodeQuery = ''; - /** - * Create a new controller instance. - * - * @return void - */ - public function __construct() - { + public function __construct( + private readonly RegistrationStatusService $registrationStatusService + ) { $this->middleware('guest', ['except' => ['getVerification', 'getVerificationError']]); } @@ -93,19 +89,8 @@ class RegisterController extends Controller public function register(RegisterRegisterRequest $request) { $error = ''; - $inviteCode = ''; $showRegister = 1; - - if ($request->has('invitecode')) { - $inviteCode = $request->input('invitecode'); - $this->inviteCodeQuery = '&invitecode='.$inviteCode; - } - - // Handle invitation token from URL (for email links) - if ($request->has('token')) { - $inviteCode = $request->input('token'); - $this->inviteCodeQuery = '&token='.$inviteCode; - } + $inviteCode = $this->extractInviteCode($request); $validator = Validator::make($request->all(), [ 'username' => ['required', 'string', 'min:5', 'max:255', 'unique:users'], @@ -162,29 +147,14 @@ class RegisterController extends Controller // Get the default user role. $userDefault = Role::query()->where('isdefault', '=', 1)->first(); - // Check invitation validity using custom system + $status = $this->registrationStatusService->resolve(); $invitationValid = $this->isInvitationValid($inviteCode, $email); - $registrationOpen = Settings::settingValue('registerstatus') === Settings::REGISTER_STATUS_OPEN; - if ($invitationValid || $registrationOpen) { - // Get invited_by from invitation if available - $invitedBy = 0; + if ($status['is_open'] || ($status['is_invite_only'] && $invitationValid)) { $invitation = null; - if (! empty($inviteCode)) { + if ($invitationValid) { $invitation = Invitation::findValidByToken($inviteCode); - if ($invitation) { - $invitedBy = $invitation->invited_by; - - // Validate email matches invitation - if (! empty($invitation->email) && ! $this->emailsMatch($invitation->email, $email)) { - return $this->redirectBackWithRegistrationError( - $request, - 'The email address you entered does not match the invitation. Please use the invited email address to register.', - 'invitation_email_mismatch' - ); - } - } } $userData = [ @@ -230,18 +200,12 @@ class RegisterController extends Controller ->with('info', 'Your account has been created. You will receive an account confirmation email shortly. Please verify your email address before logging in.'); } - $error = 'Invalid or expired invitation token!'; - $this->logRegistrationFailure( - $request, - 'invalid_or_expired_invitation', - 'The registration attempt used an invalid or expired invitation token.' - ); - break; + return $this->rejectUnavailableRegistration($request, $status, $inviteCode); case 'view': // Don't set showRegister here - let showRegistrationForm handle it // Only validate invite code if present - if (($inviteCode !== null) && ! $this->isInvitationTokenValid($inviteCode)) { + if ($inviteCode !== '' && ! $this->isInvitationTokenValid($inviteCode)) { $error = 'Invalid invitation token!'; } break; @@ -252,55 +216,55 @@ class RegisterController extends Controller public function showRegistrationForm(Request $request, string $error = '', int $showRegister = 0): mixed { - $inviteCode = ''; - if ($request->has('invitecode')) { - $inviteCode = $request->input('invitecode'); - $this->inviteCodeQuery = '&invitecode='.$inviteCode; - } - - // Handle invitation token from URL (for email links) - if ($request->has('token')) { - $inviteCode = $request->input('token'); - $this->inviteCodeQuery = '&token='.$inviteCode; - } + $inviteCode = $this->extractInviteCode($request); $emailFromInvite = ''; + $notice = ''; + $status = $this->registrationStatusService->resolve(); + + if ($status['is_open']) { + $showRegister = 1; - if ((int) Settings::settingValue('registerstatus') === Settings::REGISTER_STATUS_INVITE) { if (! empty($inviteCode)) { - if ($this->isInvitationTokenValid($inviteCode)) { - $error = ''; - $showRegister = 1; - - // Pre-fill email if invitation has one - $invitation = Invitation::findValidByToken($inviteCode); - if ($invitation && ! empty($invitation->email)) { - $emailFromInvite = $invitation->email; - } - } else { - $error = 'Invalid or expired invitation token!'; - $showRegister = 0; + $invitation = Invitation::findValidByToken($inviteCode); + if ($invitation && ! empty($invitation->email)) { + $emailFromInvite = $invitation->email; + } elseif (! $this->isInvitationTokenValid($inviteCode)) { + $notice = 'Registration is currently open, so you can register without a valid invitation.'; } + } + + if ($status['scheduled_override_active']) { + $notice = $status['message']; + } + } elseif ($status['is_invite_only']) { + if (! empty($inviteCode) && $this->isInvitationTokenValid($inviteCode)) { + $showRegister = 1; + + $invitation = Invitation::findValidByToken($inviteCode); + if ($invitation && ! empty($invitation->email)) { + $emailFromInvite = $invitation->email; + } + } elseif (! empty($inviteCode)) { + $error = 'Invalid or expired invitation token!'; + $showRegister = 0; } else { - $error = 'Registrations are currently invite only.'; + $error = $status['message']; $showRegister = 0; } - } elseif ((int) Settings::settingValue('registerstatus') === Settings::REGISTER_STATUS_CLOSED) { - $error = 'Registrations are currently closed.'; - $showRegister = 0; - } elseif ($request->has('invitecode') || $request->has('token')) { - $error = 'Registration is open, you don\'t need the invite code to register.'; - $showRegister = 0; } else { - $showRegister = 1; + $error = $status['message']; + $showRegister = 0; } return view('auth.register')->with([ 'showregister' => $showRegister, 'error' => $error, + 'notice' => $notice, 'email' => $emailFromInvite, 'invite_code_query' => $this->inviteCodeQuery, 'invitecode' => $inviteCode, + 'registrationStatus' => $status, ]); } @@ -369,19 +333,75 @@ class RegisterController extends Controller string $level = 'warning', array $context = [] ): void { + $status = $this->registrationStatusService->resolve(); + $logContext = array_merge([ 'reason' => $reason, 'message' => $message, 'username' => $request->input('username'), 'email' => $request->input('email'), 'ip' => $request->ip(), - 'registration_status' => Settings::settingValue('registerstatus'), - 'has_invite_code' => $request->filled('invitecode') || $request->filled('token'), + 'registration_status' => $status['effective_status'], + 'manual_registration_status' => $status['manual_status'], + 'registration_status_reason' => $status['reason'], + 'has_invite_code' => $request->filled('invitecode') || $request->filled('token') || $request->filled('invitation'), ], $context); Log::channel('registration')->{$level}("Registration attempt failed: {$reason}", $logContext); } + /** + * @return RedirectResponse|Redirector + */ + private function rejectUnavailableRegistration(Request $request, array $status, string $inviteCode) + { + if ($status['is_closed']) { + return $this->redirectBackWithRegistrationError( + $request, + $status['message'], + 'registrations_closed' + ); + } + + if ($status['is_invite_only']) { + if (! empty($inviteCode) && ! $this->isInvitationTokenValid($inviteCode)) { + return $this->redirectBackWithRegistrationError( + $request, + 'Invalid or expired invitation token!', + 'invalid_or_expired_invitation' + ); + } + + return $this->redirectBackWithRegistrationError( + $request, + $status['message'], + 'registrations_invite_only' + ); + } + + return $this->redirectBackWithRegistrationError( + $request, + 'Registrations are not currently available.', + 'registrations_unavailable' + ); + } + + private function extractInviteCode(Request $request): string + { + $this->inviteCodeQuery = ''; + + foreach (['invitecode', 'token', 'invitation'] as $parameter) { + if ($request->filled($parameter)) { + $inviteCode = (string) $request->input($parameter); + $this->inviteCodeQuery = '&'.$parameter.'='.$inviteCode; + + return $inviteCode; + } + } + + return ''; + } + private function emailsMatch(string $firstEmail, string $secondEmail): bool { return strcasecmp(trim($firstEmail), trim($secondEmail)) === 0; diff --git a/app/Http/Controllers/InvitationController.php b/app/Http/Controllers/InvitationController.php index b3e5859d3..16448e4fb 100644 --- a/app/Http/Controllers/InvitationController.php +++ b/app/Http/Controllers/InvitationController.php @@ -5,19 +5,23 @@ declare(strict_types=1); namespace App\Http\Controllers; use App\Models\Invitation; -use App\Models\Settings; +use App\Models\User; use App\Services\InvitationService; +use App\Services\RegistrationStatusService; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; use Illuminate\View\View; class InvitationController extends BasePageController { protected InvitationService $invitationService; - public function __construct(InvitationService $invitationService) - { + public function __construct( + InvitationService $invitationService, + private readonly RegistrationStatusService $registrationStatusService + ) { parent::__construct(); $this->invitationService = $invitationService; $this->middleware('auth')->except(['show', 'accept']); @@ -29,7 +33,7 @@ class InvitationController extends BasePageController public function index(Request $request): View { - $inviteMode = (int) Settings::settingValue('registerstatus') === Settings::REGISTER_STATUS_INVITE; + $inviteMode = $this->registrationStatusService->resolve()['is_invite_only']; $status = $request->get('status'); $this->viewData['meta_title'] = 'My Invitations'; @@ -51,7 +55,8 @@ class InvitationController extends BasePageController return view('invitations.index', $this->viewData); } - $user = auth()->user(); + /** @var User $user */ + $user = Auth::user(); $invitations = $this->invitationService->getUserInvitations($user->id, $status); $stats = $this->invitationService->getUserInvitationStats($user->id); @@ -91,7 +96,7 @@ class InvitationController extends BasePageController public function create(): View { - $inviteMode = (int) Settings::settingValue('registerstatus') === Settings::REGISTER_STATUS_INVITE; + $inviteMode = $this->registrationStatusService->resolve()['is_invite_only']; $this->viewData['meta_title'] = 'Send New Invitation'; $this->viewData['meta_keywords'] = 'invitation,invite,send,new,user'; @@ -106,7 +111,8 @@ class InvitationController extends BasePageController return view('invitations.create', $this->viewData); } - $user = auth()->user(); + /** @var User $user */ + $user = Auth::user(); // Calculate available invites (total - active pending invitations) $activeInvitations = Invitation::where('invited_by', $user->id) @@ -132,7 +138,7 @@ class InvitationController extends BasePageController */ public function store(Request $request): RedirectResponse { - if ((int) Settings::settingValue('registerstatus') !== Settings::REGISTER_STATUS_INVITE) { + if (! $this->registrationStatusService->resolve()['is_invite_only']) { return redirect()->route('invitations.index')->with('error', 'Invitations are currently disabled.'); } @@ -152,7 +158,7 @@ class InvitationController extends BasePageController $invitation = $this->invitationService->createAndSendInvitation( $request->email, - auth()->id(), + (int) Auth::id(), $expiryDays, $metadata ); @@ -188,6 +194,7 @@ class InvitationController extends BasePageController $this->viewData['preview'] = $preview; $this->viewData['token'] = $token; + $this->viewData['registrationStatus'] = $this->registrationStatusService->resolve(); // Set meta information $this->viewData['meta_title'] = $preview ? 'Invitation to Join' : 'Invalid Invitation'; @@ -202,7 +209,7 @@ class InvitationController extends BasePageController */ public function resend(int $id): RedirectResponse { - if ((int) Settings::settingValue('registerstatus') !== Settings::REGISTER_STATUS_INVITE) { + if (! $this->registrationStatusService->resolve()['is_invite_only']) { return redirect()->route('invitations.index')->with('error', 'Invitations are currently disabled.'); } @@ -210,7 +217,7 @@ class InvitationController extends BasePageController $invitation = Invitation::findOrFail($id); // Check if user owns this invitation - if ($invitation->invited_by !== auth()->id()) { + if ($invitation->invited_by !== (int) Auth::id()) { abort(403, 'Unauthorized'); } @@ -230,7 +237,7 @@ class InvitationController extends BasePageController */ public function destroy(int $id): RedirectResponse { - if ((int) Settings::settingValue('registerstatus') !== Settings::REGISTER_STATUS_INVITE) { + if (! $this->registrationStatusService->resolve()['is_invite_only']) { return redirect()->route('invitations.index')->with('error', 'Invitations are currently disabled.'); } @@ -238,7 +245,7 @@ class InvitationController extends BasePageController $invitation = Invitation::findOrFail($id); // Check if user owns this invitation - if ($invitation->invited_by !== auth()->id()) { + if ($invitation->invited_by !== (int) Auth::id()) { abort(403, 'Unauthorized'); } @@ -258,11 +265,11 @@ class InvitationController extends BasePageController */ public function stats(): JsonResponse { - if ((int) Settings::settingValue('registerstatus') !== Settings::REGISTER_STATUS_INVITE) { + if (! $this->registrationStatusService->resolve()['is_invite_only']) { return response()->json(['message' => 'Invitations are disabled'], 404); } - $stats = $this->invitationService->getUserInvitationStats(auth()->id()); + $stats = $this->invitationService->getUserInvitationStats((int) Auth::id()); return response()->json($stats); } @@ -273,7 +280,9 @@ class InvitationController extends BasePageController public function cleanup(): JsonResponse { // Check if user is admin - if (! auth()->user()->hasRole('Admin')) { + /** @var User|null $user */ + $user = Auth::user(); + if (! $user?->hasRole('Admin')) { abort(403, 'Unauthorized'); } diff --git a/app/Http/Requests/Admin/RegistrationPeriodRequest.php b/app/Http/Requests/Admin/RegistrationPeriodRequest.php new file mode 100644 index 000000000..59677fa3f --- /dev/null +++ b/app/Http/Requests/Admin/RegistrationPeriodRequest.php @@ -0,0 +1,76 @@ +merge([ + 'is_enabled' => $this->boolean('is_enabled'), + ]); + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'starts_at' => ['required', 'date'], + 'ends_at' => ['required', 'date', 'after:starts_at'], + 'is_enabled' => ['required', 'boolean'], + 'notes' => ['nullable', 'string', 'max:2000'], + ]; + } + + public function after(): array + { + return [ + function (Validator $validator): void { + if ($validator->errors()->isNotEmpty() || ! $this->boolean('is_enabled')) { + return; + } + + $startsAt = $this->date('starts_at'); + $endsAt = $this->date('ends_at'); + + if ($startsAt === null || $endsAt === null) { + return; + } + + $period = $this->route('period'); + $periodId = $period instanceof RegistrationPeriod ? $period->id : (is_numeric($period) ? (int) $period : null); + + $overlaps = RegistrationPeriod::query() + ->where('is_enabled', true) + ->when($periodId !== null, fn ($query) => $query->where('id', '!=', $periodId)) + ->where(function ($query) use ($startsAt, $endsAt) { + $query->whereBetween('starts_at', [$startsAt, $endsAt]) + ->orWhereBetween('ends_at', [$startsAt, $endsAt]) + ->orWhere(function ($query) use ($startsAt, $endsAt) { + $query->where('starts_at', '<=', $startsAt) + ->where('ends_at', '>=', $endsAt); + }); + }) + ->exists(); + + if ($overlaps) { + $validator->errors()->add('starts_at', 'Enabled registration periods cannot overlap.'); + } + }, + ]; + } +} diff --git a/app/Http/Requests/Admin/UpdateRegistrationStatusRequest.php b/app/Http/Requests/Admin/UpdateRegistrationStatusRequest.php new file mode 100644 index 000000000..8b3080300 --- /dev/null +++ b/app/Http/Requests/Admin/UpdateRegistrationStatusRequest.php @@ -0,0 +1,35 @@ + + */ + public function rules(): array + { + return [ + 'registerstatus' => [ + 'required', + 'integer', + 'in:'.implode(',', [ + Settings::REGISTER_STATUS_OPEN, + Settings::REGISTER_STATUS_INVITE, + Settings::REGISTER_STATUS_CLOSED, + ]), + ], + 'note' => ['nullable', 'string', 'max:1000'], + ]; + } +} diff --git a/app/Models/RegistrationPeriod.php b/app/Models/RegistrationPeriod.php new file mode 100644 index 000000000..655967c9b --- /dev/null +++ b/app/Models/RegistrationPeriod.php @@ -0,0 +1,86 @@ + 'datetime', + 'ends_at' => 'datetime', + 'is_enabled' => 'boolean', + ]; + + /** + * @return BelongsTo + */ + public function createdByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * @return BelongsTo + */ + public function updatedByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + + /** + * @return Builder + */ + public function scopeEnabled(Builder $query): Builder + { + return $query->where('is_enabled', true); + } + + /** + * @return Builder + */ + public function scopeActiveAt(Builder $query, ?CarbonInterface $at = null): Builder + { + $at ??= now(); + + return $query->enabled() + ->where('starts_at', '<=', $at) + ->where('ends_at', '>=', $at); + } + + /** + * @return Builder + */ + public function scopeUpcoming(Builder $query, ?CarbonInterface $at = null): Builder + { + $at ??= now(); + + return $query->where('starts_at', '>', $at); + } + + public function isActiveAt(?CarbonInterface $at = null): bool + { + $at ??= now(); + + return $this->is_enabled + && $this->starts_at !== null + && $this->ends_at !== null + && $this->starts_at->lte($at) + && $this->ends_at->gte($at); + } +} diff --git a/app/Models/RegistrationStatusHistory.php b/app/Models/RegistrationStatusHistory.php new file mode 100644 index 000000000..4db95d147 --- /dev/null +++ b/app/Models/RegistrationStatusHistory.php @@ -0,0 +1,80 @@ + 'integer', + 'new_status' => 'integer', + 'metadata' => 'array', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + /** + * @return BelongsTo + */ + public function changedByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'changed_by'); + } + + /** + * @return BelongsTo + */ + public function registrationPeriod(): BelongsTo + { + return $this->belongsTo(RegistrationPeriod::class, 'registration_period_id'); + } + + /** + * @param array $metadata + */ + public static function record( + string $action, + string $description, + ?int $changedBy = null, + ?int $oldStatus = null, + ?int $newStatus = null, + ?int $registrationPeriodId = null, + array $metadata = [] + ): self { + return self::create([ + 'action' => $action, + 'description' => $description, + 'changed_by' => $changedBy, + 'old_status' => $oldStatus, + 'new_status' => $newStatus, + 'registration_period_id' => $registrationPeriodId, + 'metadata' => $metadata, + ]); + } +} diff --git a/app/Models/Settings.php b/app/Models/Settings.php index 7529e3c7b..faf64badf 100644 --- a/app/Models/Settings.php +++ b/app/Models/Settings.php @@ -25,6 +25,7 @@ declare(strict_types=1); namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Cache; /** * Settings - model for settings table. @@ -193,5 +194,18 @@ class Settings extends Model foreach ($data as $key => $value) { self::query()->where('name', $key)->update(['value' => \is_array($value) ? implode(', ', $value) : $value]); } + + self::forgetCachedSettings(); + } + + public static function forgetCachedSettings(): void + { + Cache::forget('site_settings'); + Cache::forget('site_settings_array'); + Cache::forget('site_settings_converted'); + Cache::forget('api_v1_server_menu'); + Cache::forget('api_v2_capabilities'); + + self::$settingsCollection = null; } } diff --git a/app/Services/RegistrationFailureLogService.php b/app/Services/RegistrationFailureLogService.php new file mode 100644 index 000000000..d56de4aad --- /dev/null +++ b/app/Services/RegistrationFailureLogService.php @@ -0,0 +1,139 @@ +logsDirectory = rtrim($logsDirectory ?? storage_path('logs'), DIRECTORY_SEPARATOR); + } + + /** + * @return array> + */ + public function recentFailures(int $limit = 10): array + { + $entries = []; + + foreach ($this->registrationLogFiles() as $path) { + $lines = $this->readLines($path); + + for ($index = count($lines) - 1; $index >= 0; $index--) { + if (! str_contains($lines[$index], 'Registration attempt failed:')) { + continue; + } + + $entry = $this->parseLine($lines[$index]); + + if ($entry === null) { + continue; + } + + $entries[] = $entry; + + if (count($entries) >= $limit) { + return $entries; + } + } + } + + return $entries; + } + + /** + * @return list + */ + private function registrationLogFiles(): array + { + if (! File::isDirectory($this->logsDirectory)) { + return []; + } + + $paths = glob($this->logsDirectory.DIRECTORY_SEPARATOR.'registration*.log*') ?: []; + $paths = array_values(array_filter($paths, static fn (string $path): bool => is_file($path) && is_readable($path))); + + usort($paths, static function (string $left, string $right): int { + return filemtime($right) <=> filemtime($left); + }); + + return $paths; + } + + /** + * @return list + */ + private function readLines(string $path): array + { + $lines = []; + $file = new SplFileObject($path, 'r'); + + while (! $file->eof()) { + $line = rtrim($file->fgets(), "\r\n"); + + if ($line === '') { + continue; + } + + $lines[] = $line; + } + + return $lines; + } + + /** + * @return array|null + */ + private function parseLine(string $line): ?array + { + $timestamp = null; + $level = 'INFO'; + $message = $line; + $context = []; + + if (preg_match( + '/^\[(?[^\]]+)\]\s+[A-Za-z0-9_.-]+\.(?[A-Z]+):\s+(?.*?)\s+(?\{.*\})(?:\s+\[\])?$/', + $line, + $matches + )) { + $timestamp = $matches['timestamp']; + $level = $matches['level']; + $message = $matches['message']; + $decodedContext = json_decode($matches['context'], true); + $context = is_array($decodedContext) ? $decodedContext : []; + } elseif (preg_match( + '/^\[(?[^\]]+)\]\s+[A-Za-z0-9_.-]+\.(?[A-Z]+):\s+(?.+)$/', + $line, + $matches + )) { + $timestamp = $matches['timestamp']; + $level = $matches['level']; + $message = $matches['message']; + } + + if ($timestamp === null) { + return null; + } + + return [ + 'timestamp' => CarbonImmutable::parse($timestamp), + 'level' => strtolower($level), + 'message' => $message, + 'reason' => $context['reason'] ?? null, + 'username' => $context['username'] ?? null, + 'email' => $context['email'] ?? null, + 'ip' => $context['ip'] ?? null, + 'registration_status' => $context['registration_status'] ?? null, + 'manual_registration_status' => $context['manual_registration_status'] ?? null, + 'context' => $context, + ]; + } +} diff --git a/app/Services/RegistrationStatusService.php b/app/Services/RegistrationStatusService.php new file mode 100644 index 000000000..f6484408d --- /dev/null +++ b/app/Services/RegistrationStatusService.php @@ -0,0 +1,280 @@ + + */ + public function statusOptions(): array + { + return [ + Settings::REGISTER_STATUS_OPEN => 'Open', + Settings::REGISTER_STATUS_INVITE => 'Invite', + Settings::REGISTER_STATUS_CLOSED => 'Closed', + ]; + } + + public function statusLabel(int $status): string + { + return $this->statusOptions()[$status] ?? 'Unknown'; + } + + public function getManualStatus(): int + { + $manualStatus = (int) (Settings::settingValue('registerstatus') ?? Settings::REGISTER_STATUS_OPEN); + + if (! array_key_exists($manualStatus, $this->statusOptions())) { + return Settings::REGISTER_STATUS_OPEN; + } + + return $manualStatus; + } + + public function getActivePeriod(?CarbonInterface $at = null): ?RegistrationPeriod + { + $at ??= now(); + + return RegistrationPeriod::query() + ->activeAt($at) + ->orderBy('starts_at') + ->first(); + } + + /** + * @return array{ + * manual_status: int, + * manual_status_label: string, + * effective_status: int, + * effective_status_label: string, + * active_period: RegistrationPeriod|null, + * scheduled_override_active: bool, + * reason: string, + * message: string, + * available: bool, + * is_open: bool, + * is_invite_only: bool, + * is_closed: bool + * } + */ + public function resolve(?CarbonInterface $at = null): array + { + $at ??= now(); + + $manualStatus = $this->getManualStatus(); + $activePeriod = $this->getActivePeriod($at); + $scheduledOverrideActive = $activePeriod !== null && $manualStatus !== Settings::REGISTER_STATUS_OPEN; + $effectiveStatus = $activePeriod !== null ? Settings::REGISTER_STATUS_OPEN : $manualStatus; + $reason = match (true) { + $scheduledOverrideActive => 'scheduled_open_period', + $effectiveStatus === Settings::REGISTER_STATUS_OPEN => 'manual_open', + $effectiveStatus === Settings::REGISTER_STATUS_INVITE => 'manual_invite', + default => 'manual_closed', + }; + + return [ + 'manual_status' => $manualStatus, + 'manual_status_label' => $this->statusLabel($manualStatus), + 'effective_status' => $effectiveStatus, + 'effective_status_label' => $this->statusLabel($effectiveStatus), + 'active_period' => $activePeriod, + 'scheduled_override_active' => $scheduledOverrideActive, + 'reason' => $reason, + 'message' => $this->buildMessage($effectiveStatus, $activePeriod, $scheduledOverrideActive), + 'available' => $effectiveStatus !== Settings::REGISTER_STATUS_CLOSED, + 'is_open' => $effectiveStatus === Settings::REGISTER_STATUS_OPEN, + 'is_invite_only' => $effectiveStatus === Settings::REGISTER_STATUS_INVITE, + 'is_closed' => $effectiveStatus === Settings::REGISTER_STATUS_CLOSED, + ]; + } + + public function updateManualStatus(int $newStatus, ?User $changedBy = null, ?string $note = null): void + { + $oldStatus = $this->getManualStatus(); + + Settings::settingsUpdate([ + 'registerstatus' => $newStatus, + ]); + + if ($oldStatus === $newStatus && blank($note)) { + return; + } + + $description = $oldStatus === $newStatus + ? sprintf('Manual registration status note added while status remained %s.', $this->statusLabel($newStatus)) + : sprintf( + 'Manual registration status changed from %s to %s.', + $this->statusLabel($oldStatus), + $this->statusLabel($newStatus) + ); + + RegistrationStatusHistory::record( + RegistrationStatusHistory::ACTION_MANUAL_STATUS_CHANGED, + $description, + $changedBy?->id, + $oldStatus, + $newStatus, + null, + [ + 'note' => $note, + ] + ); + } + + /** + * @param array{name: string, starts_at: mixed, ends_at: mixed, is_enabled: bool, notes?: string|null} $data + */ + public function createPeriod(array $data, ?User $changedBy = null): RegistrationPeriod + { + $period = RegistrationPeriod::query()->create([ + 'name' => $data['name'], + 'starts_at' => $data['starts_at'], + 'ends_at' => $data['ends_at'], + 'is_enabled' => $data['is_enabled'], + 'notes' => $data['notes'] ?? null, + 'created_by' => $changedBy?->id, + 'updated_by' => $changedBy?->id, + ]); + + RegistrationStatusHistory::record( + RegistrationStatusHistory::ACTION_PERIOD_CREATED, + sprintf('Scheduled open period "%s" created.', $period->name), + $changedBy?->id, + null, + null, + $period->id, + [ + 'period' => $this->serializePeriod($period), + ] + ); + + return $period; + } + + /** + * @param array{name: string, starts_at: mixed, ends_at: mixed, is_enabled: bool, notes?: string|null} $data + */ + public function updatePeriod(RegistrationPeriod $period, array $data, ?User $changedBy = null): RegistrationPeriod + { + $before = $this->serializePeriod($period); + + $period->fill([ + 'name' => $data['name'], + 'starts_at' => $data['starts_at'], + 'ends_at' => $data['ends_at'], + 'is_enabled' => $data['is_enabled'], + 'notes' => $data['notes'] ?? null, + 'updated_by' => $changedBy?->id, + ]); + $period->save(); + + RegistrationStatusHistory::record( + RegistrationStatusHistory::ACTION_PERIOD_UPDATED, + sprintf('Scheduled open period "%s" updated.', $period->name), + $changedBy?->id, + null, + null, + $period->id, + [ + 'before' => $before, + 'after' => $this->serializePeriod($period), + ] + ); + + return $period; + } + + public function togglePeriod(RegistrationPeriod $period, ?User $changedBy = null, ?string $note = null): RegistrationPeriod + { + $period->forceFill([ + 'is_enabled' => ! $period->is_enabled, + 'updated_by' => $changedBy?->id, + ])->save(); + + RegistrationStatusHistory::record( + RegistrationStatusHistory::ACTION_PERIOD_TOGGLED, + sprintf( + 'Scheduled open period "%s" %s.', + $period->name, + $period->is_enabled ? 'enabled' : 'disabled' + ), + $changedBy?->id, + null, + null, + $period->id, + [ + 'note' => $note, + 'period' => $this->serializePeriod($period), + ] + ); + + return $period; + } + + public function deletePeriod(RegistrationPeriod $period, ?User $changedBy = null, ?string $note = null): void + { + $snapshot = $this->serializePeriod($period); + $periodName = $period->name; + + $period->delete(); + + RegistrationStatusHistory::record( + RegistrationStatusHistory::ACTION_PERIOD_DELETED, + sprintf('Scheduled open period "%s" deleted.', $periodName), + $changedBy?->id, + null, + null, + null, + [ + 'note' => $note, + 'period' => $snapshot, + ] + ); + } + + private function buildMessage( + int $effectiveStatus, + ?RegistrationPeriod $activePeriod, + bool $scheduledOverrideActive + ): string { + if ($scheduledOverrideActive && $activePeriod !== null) { + return sprintf( + 'Registrations are temporarily open until %s because of the scheduled period "%s".', + $activePeriod->ends_at->format('Y-m-d H:i'), + $activePeriod->name + ); + } + + return match ($effectiveStatus) { + Settings::REGISTER_STATUS_OPEN => 'Registrations are currently open.', + Settings::REGISTER_STATUS_INVITE => 'Registrations are currently invite only.', + default => 'Registrations are currently closed.', + }; + } + + /** + * @return array + */ + private function serializePeriod(RegistrationPeriod $period): array + { + return [ + 'id' => $period->id, + 'name' => $period->name, + 'starts_at' => $period->starts_at?->toDateTimeString(), + 'ends_at' => $period->ends_at?->toDateTimeString(), + 'is_enabled' => $period->is_enabled, + 'notes' => $period->notes, + 'created_by' => $period->created_by, + 'updated_by' => $period->updated_by, + ]; + } +} diff --git a/database/migrations/2026_03_10_000000_create_registration_periods_table.php b/database/migrations/2026_03_10_000000_create_registration_periods_table.php new file mode 100644 index 000000000..c16fbd193 --- /dev/null +++ b/database/migrations/2026_03_10_000000_create_registration_periods_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('name'); + $table->dateTime('starts_at')->index(); + $table->dateTime('ends_at')->index(); + $table->boolean('is_enabled')->default(true)->index(); + $table->text('notes')->nullable(); + $table->unsignedBigInteger('created_by')->nullable()->index(); + $table->unsignedBigInteger('updated_by')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('registration_periods'); + } +}; diff --git a/database/migrations/2026_03_10_000001_create_registration_status_history_table.php b/database/migrations/2026_03_10_000001_create_registration_status_history_table.php new file mode 100644 index 000000000..e95553cf4 --- /dev/null +++ b/database/migrations/2026_03_10_000001_create_registration_status_history_table.php @@ -0,0 +1,35 @@ +id(); + $table->string('action', 100)->index(); + $table->unsignedTinyInteger('old_status')->nullable(); + $table->unsignedTinyInteger('new_status')->nullable(); + $table->unsignedBigInteger('registration_period_id')->nullable()->index(); + $table->unsignedBigInteger('changed_by')->nullable()->index(); + $table->string('description'); + $table->json('metadata')->nullable(); + $table->timestamp('created_at')->useCurrent()->index(); + $table->timestamp('updated_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('registration_status_history'); + } +}; diff --git a/resources/views/admin/registrations/index.blade.php b/resources/views/admin/registrations/index.blade.php new file mode 100644 index 000000000..37c121322 --- /dev/null +++ b/resources/views/admin/registrations/index.blade.php @@ -0,0 +1,378 @@ +@extends('layouts.admin') + +@section('content') +@php + use Illuminate\Support\Str; + + $statusBadgeClasses = static function (int $status): string { + return match ($status) { + \App\Models\Settings::REGISTER_STATUS_OPEN => 'border border-emerald-500/30 bg-emerald-600 text-white shadow-sm dark:border-emerald-300/20 dark:bg-emerald-500 dark:text-slate-950', + \App\Models\Settings::REGISTER_STATUS_INVITE => 'border border-amber-500/30 bg-amber-500 text-slate-950 shadow-sm dark:border-amber-200/20 dark:bg-amber-400 dark:text-slate-950', + default => 'border border-rose-500/30 bg-rose-600 text-white shadow-sm dark:border-rose-200/20 dark:bg-rose-500 dark:text-white', + }; + }; + $periodStatusClasses = static function (bool $isEnabled): string { + return $isEnabled + ? 'border border-emerald-500/30 bg-emerald-600 text-white shadow-sm dark:border-emerald-300/20 dark:bg-emerald-500 dark:text-slate-950' + : 'border border-slate-400/30 bg-slate-600 text-white shadow-sm dark:border-slate-300/20 dark:bg-slate-500 dark:text-slate-950'; + }; + $primaryButtonClasses = 'inline-flex items-center rounded-lg border border-blue-700 bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-blue-300/20 dark:bg-blue-500 dark:text-white dark:hover:bg-blue-400 dark:focus:ring-offset-gray-900'; + $secondaryButtonClasses = 'inline-flex items-center rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-gray-500 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600 dark:focus:ring-offset-gray-900'; + $editButtonClasses = 'inline-flex items-center rounded-md border border-sky-700 bg-sky-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 dark:border-sky-300/20 dark:bg-sky-500 dark:text-white dark:hover:bg-sky-400 dark:focus:ring-offset-gray-900'; + $toggleButtonClasses = 'inline-flex items-center rounded-md border border-amber-500/40 bg-amber-500 px-3 py-1.5 text-xs font-medium text-slate-950 shadow-sm transition hover:bg-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 dark:border-amber-200/20 dark:bg-amber-400 dark:text-slate-950 dark:hover:bg-amber-300 dark:focus:ring-offset-gray-900'; + $deleteButtonClasses = 'inline-flex items-center rounded-md border border-rose-700 bg-rose-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-rose-500 focus:ring-offset-2 dark:border-rose-300/20 dark:bg-rose-500 dark:text-white dark:hover:bg-rose-400 dark:focus:ring-offset-gray-900'; +@endphp + +
+ @if($errors->any()) +
+

Please fix the following issues:

+
    + @foreach($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + + + + +
+
+

Effective Status

+
+ + {{ $registrationStatus['effective_status_label'] }} + + @if($registrationStatus['scheduled_override_active']) + + Scheduled Override + + @endif +
+

{{ $registrationStatus['message'] }}

+
+ +
+

Manual Baseline

+
+ + {{ $registrationStatus['manual_status_label'] }} + +
+

+ This is the fallback mode used whenever no scheduled open period is active. +

+
+ +
+

Active Open Period

+ @if($registrationStatus['active_period']) +
+

{{ $registrationStatus['active_period']->name }}

+

+ {{ $registrationStatus['active_period']->starts_at->format('Y-m-d H:i') }} to + {{ $registrationStatus['active_period']->ends_at->format('Y-m-d H:i') }} +

+
+ @else +

No scheduled open-registration period is active right now.

+ @endif +
+
+
+ +
+ +
+

Manual Status

+

Change the baseline site registration mode. Scheduled open periods can temporarily override this selection.

+
+
+ @csrf + + + + @foreach($statusOptions as $statusValue => $statusLabel) + + @endforeach + + + + + + + + +
+
+ + +
+

+ {{ $editingPeriod ? 'Edit Scheduled Open Period' : 'Create Scheduled Open Period' }} +

+

Scheduled periods temporarily make public registration open without changing the saved baseline mode.

+
+ +
+ @csrf + @if($editingPeriod) + @method('PUT') + @endif + + + + + +
+ + + + + + + +
+ + + + + + + +
+ + + @if($editingPeriod) + + Cancel Edit + + @endif +
+
+
+
+ + +
+

Scheduled Open Periods

+

Manage temporary windows when registrations should be publicly open.

+
+ + @if($periods->isEmpty()) +
+ +

No scheduled open-registration periods have been created yet.

+
+ @else +
+ + + + + + + + + + + + + @foreach($periods as $period) + + + + + + + + + @endforeach + +
NameStatusWindowNotesUpdated ByActions
+

{{ $period->name }}

+ @if($registrationStatus['active_period'] && $registrationStatus['active_period']->id === $period->id) + + Active Right Now + + @endif +
+ + {{ $period->is_enabled ? 'Enabled' : 'Disabled' }} + + +

{{ $period->starts_at->format('Y-m-d H:i') }}

+

to {{ $period->ends_at->format('Y-m-d H:i') }}

+
+ {{ $period->notes ? Str::limit($period->notes, 100) : '—' }} + + {{ $period->updatedByUser?->username ?? $period->createdByUser?->username ?? 'System' }} + +
+ + Edit + + +
+ @csrf + +
+ +
+ @csrf + @method('DELETE') + +
+
+
+
+ @endif +
+ +
+ +
+

Registration History

+

Recent manual status changes and scheduled-period actions.

+
+ + @if($history->isEmpty()) +
+ +

No registration history has been recorded yet.

+
+ @else +
+ @foreach($history as $entry) +
+
+
+

{{ $entry->description }}

+ @if(!empty($entry->metadata['note'])) +

{{ $entry->metadata['note'] }}

+ @endif +
+ + {{ $entry->created_at?->format('Y-m-d H:i:s') }} + +
+
+ Changed by: {{ $entry->changedByUser?->username ?? 'System' }} +
+
+ @endforeach +
+ @endif +
+ + +
+

Recent Registration Activity

+

Successful signups come from `UserActivity`; failed attempts come from the dedicated registration log.

+
+ +
+
+
+

Successful Signups

+
+ @forelse($recentSuccessfulRegistrations as $activity) +
+

{{ $activity->username }}

+

{{ $activity->metadata['email'] }}

+
+ IP: {{ $activity->metadata['ip_address'] }} + {{ $activity->created_at?->format('Y-m-d H:i:s') }} +
+
+ @empty +
+ No successful registrations have been recorded recently. +
+ @endforelse +
+ +
+
+

Failed Attempts

+
+ @forelse($recentFailedAttempts as $failure) +
+

{{ $failure['reason'] ?? 'registration_failed' }}

+

+ {{ $failure['email'] ?? 'Unknown email' }} + @if(!empty($failure['username'])) + ({{ $failure['username'] }}) + @endif +

+
+ @if(!empty($failure['ip'])) + IP: {{ $failure['ip'] }} + @endif + @if(!empty($failure['manual_registration_status_label'])) + Manual: {{ $failure['manual_registration_status_label'] }} + @endif + @if(!empty($failure['registration_status_label'])) + Effective: {{ $failure['registration_status_label'] }} + @endif + {{ $failure['timestamp']->format('Y-m-d H:i:s') }} +
+
+ @empty +
+ No recent failed registration attempts were found in the registration log. +
+ @endforelse +
+
+
+
+
+@endsection diff --git a/resources/views/admin/site/edit.blade.php b/resources/views/admin/site/edit.blade.php index 132a76cf2..0668c5bcb 100644 --- a/resources/views/admin/site/edit.blade.php +++ b/resources/views/admin/site/edit.blade.php @@ -467,17 +467,18 @@
-
diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php index 156881b9b..bd9c02154 100644 --- a/resources/views/auth/register.blade.php +++ b/resources/views/auth/register.blade.php @@ -40,6 +40,15 @@
@endif + @if(!empty($notice)) +
+
+ +

{{ $notice }}

+
+
+ @endif + @if($errors->any())
@@ -59,7 +68,7 @@ @if($showregister == 1) -
+ @csrf @@ -101,7 +110,7 @@ id="email" type="email" name="email" - value="{{ old('email') }}" + value="{{ old('email', $email ?? '') }}" required class="block w-full pl-10 pr-3 py-3 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition @error('email') border-red-500 @enderror" placeholder="your@email.com" diff --git a/resources/views/invitations/show.blade.php b/resources/views/invitations/show.blade.php index 5f4477170..b9f4187f1 100644 --- a/resources/views/invitations/show.blade.php +++ b/resources/views/invitations/show.blade.php @@ -136,8 +136,14 @@ Contact Support + @elseif($registrationStatus['is_closed']) +
+ +
Registrations are currently closed
+

{{ $registrationStatus['message'] }}

+
@else - + Accept Invitation & Create Account
@@ -156,9 +162,11 @@ Contact Support - - Register Without Invitation - + @if($registrationStatus['is_open']) + + Register Without Invitation + + @endif
@endif diff --git a/resources/views/partials/admin-menu.blade.php b/resources/views/partials/admin-menu.blade.php index c15343142..eb3f6ea6f 100644 --- a/resources/views/partials/admin-menu.blade.php +++ b/resources/views/partials/admin-menu.blade.php @@ -294,6 +294,9 @@ Site Settings + + Registrations + Tmux diff --git a/routes/web.php b/routes/web.php index 99d1b99a3..900da3917 100644 --- a/routes/web.php +++ b/routes/web.php @@ -30,6 +30,7 @@ use App\Http\Controllers\Admin\AdminMusicController; use App\Http\Controllers\Admin\AdminPageController; use App\Http\Controllers\Admin\AdminPredbController; use App\Http\Controllers\Admin\AdminPromotionController; +use App\Http\Controllers\Admin\AdminRegistrationController; use App\Http\Controllers\Admin\AdminReleaseNamingRegexesController; use App\Http\Controllers\Admin\AdminReleasesController; use App\Http\Controllers\Admin\AdminRoleController; @@ -212,6 +213,12 @@ Route::middleware(['role:Admin', '2fa'])->prefix('admin')->group(function () { Route::post('resendverification', [AdminUserController::class, 'resendVerification'])->name('admin.resend-verification'); Route::get('user-role-history', [AdminUserRoleHistoryController::class, 'index'])->name('admin.user-role-history'); Route::get('user-role-history/{userId}', [AdminUserRoleHistoryController::class, 'show'])->name('admin.user-role-history.show'); + Route::get('registrations', [AdminRegistrationController::class, 'index'])->name('admin.registrations.index'); + Route::post('registrations/status', [AdminRegistrationController::class, 'updateStatus'])->name('admin.registrations.update-status'); + Route::post('registrations/periods', [AdminRegistrationController::class, 'storePeriod'])->name('admin.registrations.periods.store'); + Route::put('registrations/periods/{period}', [AdminRegistrationController::class, 'updatePeriod'])->name('admin.registrations.periods.update'); + Route::post('registrations/periods/{period}/toggle', [AdminRegistrationController::class, 'togglePeriod'])->name('admin.registrations.periods.toggle'); + Route::delete('registrations/periods/{period}', [AdminRegistrationController::class, 'destroyPeriod'])->name('admin.registrations.periods.destroy'); Route::match(['GET', 'POST'], 'site-edit', [AdminSiteController::class, 'edit'])->name('admin.site-edit'); Route::get('site-stats', [AdminSiteController::class, 'stats'])->name('admin.site-stats'); Route::get('logs', [AdminLogViewerController::class, 'index'])->name('admin.logs.index'); diff --git a/tests/Feature/AdminRegistrationControllerTest.php b/tests/Feature/AdminRegistrationControllerTest.php new file mode 100644 index 000000000..187bb9ee4 --- /dev/null +++ b/tests/Feature/AdminRegistrationControllerTest.php @@ -0,0 +1,429 @@ + 'sqlite', + 'database.connections.sqlite.database' => ':memory:', + 'mail.from.address' => '', + 'app.key' => 'base64:'.base64_encode(random_bytes(32)), + ]); + + DB::purge(); + DB::reconnect(); + Cache::flush(); + Queue::fake(); + + $this->createSchema(); + $this->seedSettings(); + $this->resetGlobalComposerState(); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + $this->withoutMiddleware(Google2FAMiddleware::class); + + $this->logNamespace = 'registration-admin-tests/'.Str::uuid()->toString(); + File::ensureDirectoryExists(storage_path('logs/'.$this->logNamespace)); + $this->app->bind(RegistrationFailureLogService::class, function () { + return new RegistrationFailureLogService(storage_path('logs/'.$this->logNamespace)); + }); + } + + protected function tearDown(): void + { + if ($this->logNamespace !== '') { + File::deleteDirectory(storage_path('logs/'.$this->logNamespace)); + } + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + + parent::tearDown(); + } + + public function test_admin_can_view_registration_admin_page_with_recent_activity(): void + { + $admin = $this->createUserWithRole('Admin'); + /** @var Authenticatable $authenticatedAdmin */ + $authenticatedAdmin = $admin; + + DB::table('registration_periods')->insert([ + 'name' => 'Weekend Open', + 'starts_at' => now()->subHour(), + 'ends_at' => now()->addHour(), + 'is_enabled' => true, + 'created_by' => $admin->id, + 'updated_by' => $admin->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('user_activities')->insert([ + 'user_id' => 999, + 'username' => 'freshmember', + 'activity_type' => 'registered', + 'description' => 'New user registered: freshmember', + 'metadata' => json_encode([ + 'email' => 'freshmember@example.com', + 'ip_address' => '127.0.0.1', + ]), + 'created_at' => now(), + ]); + + DB::table('registration_status_history')->insert([ + 'action' => RegistrationStatusHistory::ACTION_MANUAL_STATUS_CHANGED, + 'old_status' => Settings::REGISTER_STATUS_OPEN, + 'new_status' => Settings::REGISTER_STATUS_INVITE, + 'changed_by' => $admin->id, + 'description' => 'Manual registration status changed from Open to Invite.', + 'metadata' => json_encode(['note' => 'Temporary slowdown']), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->createRegistrationLogFile([ + '[2026-03-10 12:00:00] testing.WARNING: Registration attempt failed: invalid_or_expired_invitation {"reason":"invalid_or_expired_invitation","email":"invalid@example.com","username":"badinvite","ip":"10.1.1.2","registration_status":1,"manual_registration_status":1} []', + ]); + + $response = $this->actingAs($authenticatedAdmin)->get(route('admin.registrations.index')); + + $response->assertOk(); + $response->assertSee('Registration Admin'); + $response->assertSee('Weekend Open'); + $response->assertSee('freshmember@example.com'); + $response->assertSee('invalid_or_expired_invitation'); + $response->assertSee('Temporary slowdown'); + } + + public function test_admin_can_update_manual_registration_status_and_record_history(): void + { + $admin = $this->createUserWithRole('Admin'); + /** @var Authenticatable $authenticatedAdmin */ + $authenticatedAdmin = $admin; + + $this->actingAs($authenticatedAdmin) + ->post(route('admin.registrations.update-status'), [ + 'registerstatus' => Settings::REGISTER_STATUS_CLOSED, + 'note' => 'Closing signups for maintenance.', + ]) + ->assertRedirect(route('admin.registrations.index')) + ->assertSessionHas('success', 'Manual registration status updated successfully.'); + + $this->assertSame( + Settings::REGISTER_STATUS_CLOSED, + (int) DB::table('settings')->where('name', 'registerstatus')->value('value') + ); + + $history = DB::table('registration_status_history') + ->where('action', RegistrationStatusHistory::ACTION_MANUAL_STATUS_CHANGED) + ->latest('id') + ->first(); + + $this->assertNotNull($history); + $this->assertSame(Settings::REGISTER_STATUS_OPEN, $history->old_status); + $this->assertSame(Settings::REGISTER_STATUS_CLOSED, $history->new_status); + $this->assertSame($admin->id, $history->changed_by); + $this->assertStringContainsString('maintenance', (string) $history->metadata); + } + + public function test_admin_can_manage_scheduled_periods_and_api_capabilities_follow_effective_status(): void + { + DB::table('settings') + ->where('name', 'registerstatus') + ->update(['value' => (string) Settings::REGISTER_STATUS_CLOSED]); + + $admin = $this->createUserWithRole('Admin'); + /** @var Authenticatable $authenticatedAdmin */ + $authenticatedAdmin = $admin; + + $this->actingAs($authenticatedAdmin) + ->post(route('admin.registrations.periods.store'), [ + 'name' => 'Launch Window', + 'starts_at' => now()->subHour()->format('Y-m-d\TH:i'), + 'ends_at' => now()->addHour()->format('Y-m-d\TH:i'), + 'is_enabled' => '1', + 'notes' => 'Temporarily open registrations after deploy.', + ]) + ->assertRedirect(route('admin.registrations.index')); + + $periodId = (int) DB::table('registration_periods')->value('id'); + $this->assertGreaterThan(0, $periodId); + + $this->getJson('/api/v2/capabilities') + ->assertOk() + ->assertJsonPath('registration.available', 'yes') + ->assertJsonPath('registration.open', 'yes'); + + $apiController = app(ApiController::class); + $reflection = new ReflectionClass($apiController); + $typeProperty = $reflection->getProperty('type'); + $typeProperty->setAccessible(true); + $typeProperty->setValue($apiController, 'caps'); + + $menu = $apiController->getForMenu(); + $this->assertSame('yes', $menu['registration']['available']); + $this->assertSame('yes', $menu['registration']['open']); + + $this->actingAs($authenticatedAdmin) + ->put(route('admin.registrations.periods.update', $periodId), [ + 'name' => 'Launch Window Updated', + 'starts_at' => now()->subMinutes(30)->format('Y-m-d\TH:i'), + 'ends_at' => now()->addHours(2)->format('Y-m-d\TH:i'), + 'is_enabled' => '1', + 'notes' => 'Extended after launch demand.', + ]) + ->assertRedirect(route('admin.registrations.index')); + + $this->assertSame( + 'Launch Window Updated', + DB::table('registration_periods')->where('id', $periodId)->value('name') + ); + + $this->actingAs($authenticatedAdmin) + ->post(route('admin.registrations.periods.toggle', $periodId)) + ->assertRedirect(route('admin.registrations.index')); + + $this->assertSame(0, (int) DB::table('registration_periods')->where('id', $periodId)->value('is_enabled')); + + $this->getJson('/api/v2/capabilities') + ->assertOk() + ->assertJsonPath('registration.available', 'no') + ->assertJsonPath('registration.open', 'no'); + + $this->actingAs($authenticatedAdmin) + ->delete(route('admin.registrations.periods.destroy', $periodId)) + ->assertRedirect(route('admin.registrations.index')); + + $this->assertNull(DB::table('registration_periods')->where('id', $periodId)->first()); + + $actions = DB::table('registration_status_history') + ->pluck('action') + ->all(); + + $this->assertContains(RegistrationStatusHistory::ACTION_PERIOD_CREATED, $actions); + $this->assertContains(RegistrationStatusHistory::ACTION_PERIOD_UPDATED, $actions); + $this->assertContains(RegistrationStatusHistory::ACTION_PERIOD_TOGGLED, $actions); + $this->assertContains(RegistrationStatusHistory::ACTION_PERIOD_DELETED, $actions); + } + + private function createSchema(): void + { + Schema::create('settings', function (Blueprint $table): void { + $table->string('name')->primary(); + $table->text('value')->nullable(); + }); + + Schema::create('roles', function (Blueprint $table): void { + $table->increments('id'); + $table->string('name'); + $table->string('guard_name'); + $table->integer('rate_limit')->default(60); + $table->boolean('isdefault')->default(false); + $table->unsignedInteger('defaultinvites')->default(0); + $table->timestamps(); + }); + + Schema::create('permissions', function (Blueprint $table): void { + $table->increments('id'); + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + }); + + Schema::create('users', function (Blueprint $table): void { + $table->increments('id'); + $table->string('username'); + $table->string('email')->unique(); + $table->string('password'); + $table->unsignedInteger('roles_id')->default(1); + $table->integer('rate_limit')->default(60); + $table->string('api_token')->nullable(); + $table->boolean('verified')->default(true); + $table->boolean('can_post')->default(true); + $table->string('theme_preference', 10)->default('light'); + $table->timestamp('email_verified_at')->nullable(); + $table->timestamp('lastlogin')->nullable(); + $table->rememberToken(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('model_has_roles', function (Blueprint $table): void { + $table->unsignedInteger('role_id'); + $table->string('model_type'); + $table->unsignedInteger('model_id'); + $table->primary(['role_id', 'model_id', 'model_type']); + }); + + Schema::create('model_has_permissions', function (Blueprint $table): void { + $table->unsignedInteger('permission_id'); + $table->string('model_type'); + $table->unsignedInteger('model_id'); + $table->primary(['permission_id', 'model_id', 'model_type']); + }); + + Schema::create('role_has_permissions', function (Blueprint $table): void { + $table->unsignedInteger('permission_id'); + $table->unsignedInteger('role_id'); + $table->primary(['permission_id', 'role_id']); + }); + + Schema::create('categories', function (Blueprint $table): void { + $table->increments('id'); + $table->string('title')->default(''); + $table->unsignedInteger('root_categories_id')->nullable(); + $table->integer('status')->default(1); + }); + + Schema::create('root_categories', function (Blueprint $table): void { + $table->increments('id'); + $table->string('title')->default(''); + $table->integer('status')->default(1); + }); + + Schema::create('user_excluded_categories', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('users_id'); + $table->unsignedInteger('categories_id'); + }); + + Schema::create('content', function (Blueprint $table): void { + $table->increments('id'); + $table->string('title')->default(''); + $table->string('url')->nullable(); + $table->text('body')->nullable(); + $table->text('metadescription')->nullable(); + $table->text('metakeywords')->nullable(); + $table->integer('contenttype')->default(1); + $table->integer('status')->default(1); + $table->integer('ordinal')->nullable(); + $table->integer('role')->default(0); + }); + + Schema::create('user_activities', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('user_id')->nullable(); + $table->string('username'); + $table->string('activity_type', 50); + $table->text('description'); + $table->json('metadata')->nullable(); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('registration_periods', function (Blueprint $table): void { + $table->increments('id'); + $table->string('name'); + $table->dateTime('starts_at'); + $table->dateTime('ends_at'); + $table->boolean('is_enabled')->default(true); + $table->text('notes')->nullable(); + $table->unsignedInteger('created_by')->nullable(); + $table->unsignedInteger('updated_by')->nullable(); + $table->timestamps(); + }); + + Schema::create('registration_status_history', function (Blueprint $table): void { + $table->increments('id'); + $table->string('action', 100); + $table->unsignedTinyInteger('old_status')->nullable(); + $table->unsignedTinyInteger('new_status')->nullable(); + $table->unsignedInteger('registration_period_id')->nullable(); + $table->unsignedInteger('changed_by')->nullable(); + $table->string('description'); + $table->json('metadata')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + }); + } + + private function seedSettings(): void + { + DB::table('settings')->insert([ + ['name' => 'registerstatus', 'value' => (string) Settings::REGISTER_STATUS_OPEN], + ['name' => 'title', 'value' => 'NNTmux Test'], + ['name' => 'home_link', 'value' => '/'], + ]); + + DB::table('root_categories')->insert([ + 'id' => 1, + 'title' => 'Movies', + 'status' => 1, + ]); + } + + private function createUserWithRole(string $roleName): User + { + $role = Role::query()->firstOrCreate( + [ + 'name' => $roleName, + 'guard_name' => 'web', + ], + [ + 'rate_limit' => 60, + 'isdefault' => $roleName === 'User', + 'defaultinvites' => 1, + ] + ); + + $user = User::query()->create([ + 'username' => strtolower($roleName).'_'.Str::random(8), + 'email' => Str::random(12).'@example.test', + 'password' => bcrypt('password'), + 'roles_id' => $role->id, + 'rate_limit' => 60, + 'api_token' => Str::random(32), + 'verified' => true, + 'email_verified_at' => now(), + 'lastlogin' => now(), + ]); + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + + return $user->fresh(); + } + + private function resetGlobalComposerState(): void + { + $reflection = new ReflectionClass(\App\View\Composers\GlobalDataComposer::class); + $property = $reflection->getProperty('resolvedData'); + $property->setAccessible(true); + $property->setValue(null, null); + } + + /** + * @param list $lines + */ + private function createRegistrationLogFile(array $lines): void + { + $absolutePath = storage_path('logs/'.$this->logNamespace.'/registration.log'); + File::ensureDirectoryExists(dirname($absolutePath)); + File::put($absolutePath, implode(PHP_EOL, $lines).PHP_EOL); + clearstatcache(true, $absolutePath); + } +} diff --git a/tests/Feature/RegisterControllerTest.php b/tests/Feature/RegisterControllerTest.php index c52704fff..f98327ce7 100644 --- a/tests/Feature/RegisterControllerTest.php +++ b/tests/Feature/RegisterControllerTest.php @@ -6,6 +6,7 @@ namespace Tests\Feature; use App\Http\Controllers\Auth\RegisterController; use App\Models\User; +use App\Services\RegistrationStatusService; use Illuminate\Contracts\Validation\UncompromisedVerifier; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Cache; @@ -13,7 +14,6 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Schema; use Mockery; -use Mockery\MockInterface; use Tests\TestCase; class RegisterControllerTest extends TestCase @@ -88,6 +88,31 @@ class RegisterControllerTest extends TestCase $table->softDeletes(); }); + Schema::create('registration_periods', function (Blueprint $table): void { + $table->increments('id'); + $table->string('name'); + $table->dateTime('starts_at'); + $table->dateTime('ends_at'); + $table->boolean('is_enabled')->default(true); + $table->text('notes')->nullable(); + $table->unsignedInteger('created_by')->nullable(); + $table->unsignedInteger('updated_by')->nullable(); + $table->timestamps(); + }); + + Schema::create('invitations', function (Blueprint $table): void { + $table->increments('id'); + $table->string('token')->unique(); + $table->string('email')->nullable(); + $table->unsignedInteger('invited_by'); + $table->timestamp('expires_at'); + $table->timestamp('used_at')->nullable(); + $table->unsignedInteger('used_by')->nullable(); + $table->boolean('is_active')->default(true); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + DB::table('settings')->insert([ 'name' => 'registerstatus', 'value' => '0', @@ -106,17 +131,12 @@ class RegisterControllerTest extends TestCase public function test_successful_registration_redirects_to_login_with_confirmation_message(): void { - $this->partialMock(RegisterController::class, function (MockInterface $mock): void { - $mock->shouldAllowMockingProtectedMethods(); - $mock->shouldReceive('create') - ->once() - ->andReturn(new User([ - 'id' => 123, - 'username' => 'newmember', - 'email' => 'newmember@example.com', - 'roles_id' => 1, - ])); - }); + $this->mockRegisterControllerCreate(new User([ + 'id' => 123, + 'username' => 'newmember', + 'email' => 'newmember@example.com', + 'roles_id' => 1, + ])); $response = $this->post(route('register.post'), [ 'action' => 'submit', @@ -203,12 +223,9 @@ class RegisterControllerTest extends TestCase }) ); - $this->partialMock(RegisterController::class, function (MockInterface $mock): void { - $mock->shouldAllowMockingProtectedMethods(); - $mock->shouldReceive('create') - ->once() - ->andThrow(new \RuntimeException('Simulated registration failure')); - }); + $this->mockRegisterControllerCreate( + new \RuntimeException('Simulated registration failure') + ); $response = $this->from(route('register'))->post(route('register.post'), [ 'action' => 'submit', @@ -224,4 +241,133 @@ class RegisterControllerTest extends TestCase 'registration' => 'We could not complete your registration right now. Please try again in a few minutes. If the problem continues, contact support.', ]); } + + public function test_invite_only_registration_with_valid_invitation_succeeds(): void + { + DB::table('settings') + ->where('name', 'registerstatus') + ->update(['value' => '1']); + + DB::table('invitations')->insert([ + 'token' => 'valid-token', + 'email' => 'invitee@example.com', + 'invited_by' => 1, + 'expires_at' => now()->addDay(), + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->mockRegisterControllerCreate(new User([ + 'id' => 321, + 'username' => 'inviteemember', + 'email' => 'invitee@example.com', + 'roles_id' => 1, + ])); + + $response = $this->post(route('register.post'), [ + 'action' => 'submit', + 'token' => 'valid-token', + 'username' => 'inviteemember', + 'email' => 'invitee@example.com', + 'password' => 'ValidPass1!', + 'password_confirmation' => 'ValidPass1!', + 'terms' => 'on', + ]); + + $response->assertRedirect(route('login')); + + $this->assertNotNull( + DB::table('invitations') + ->where('token', 'valid-token') + ->value('used_at') + ); + } + + public function test_closed_registration_rejects_even_valid_invitation(): void + { + DB::table('settings') + ->where('name', 'registerstatus') + ->update(['value' => '2']); + + DB::table('invitations')->insert([ + 'token' => 'closed-token', + 'email' => 'invitee@example.com', + 'invited_by' => 1, + 'expires_at' => now()->addDay(), + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $response = $this->from(route('register'))->post(route('register.post'), [ + 'action' => 'submit', + 'token' => 'closed-token', + 'username' => 'closedmember', + 'email' => 'invitee@example.com', + 'password' => 'ValidPass1!', + 'password_confirmation' => 'ValidPass1!', + 'terms' => 'on', + ]); + + $response->assertRedirect(route('register')); + $response->assertSessionHasErrors([ + 'registration' => 'Registrations are currently closed.', + ]); + } + + public function test_scheduled_open_period_allows_registration_when_manual_status_is_closed(): void + { + DB::table('settings') + ->where('name', 'registerstatus') + ->update(['value' => '2']); + + DB::table('registration_periods')->insert([ + 'name' => 'Weekend Open', + 'starts_at' => now()->subHour(), + 'ends_at' => now()->addHour(), + 'is_enabled' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->mockRegisterControllerCreate(new User([ + 'id' => 456, + 'username' => 'scheduledmember', + 'email' => 'scheduled@example.com', + 'roles_id' => 1, + ])); + + $response = $this->post(route('register.post'), [ + 'action' => 'submit', + 'username' => 'scheduledmember', + 'email' => 'scheduled@example.com', + 'password' => 'ValidPass1!', + 'password_confirmation' => 'ValidPass1!', + 'terms' => 'on', + ]); + + $response->assertRedirect(route('login')); + } + + private function mockRegisterControllerCreate(User|\Throwable $result): void + { + $mock = Mockery::mock(RegisterController::class)->makePartial(); + + $mock->shouldAllowMockingProtectedMethods(); + + $reflection = new \ReflectionProperty(RegisterController::class, 'registrationStatusService'); + $reflection->setAccessible(true); + $reflection->setValue($mock, app(RegistrationStatusService::class)); + + $expectation = $mock->shouldReceive('create')->once(); + + if ($result instanceof \Throwable) { + $expectation->andThrow($result); + } else { + $expectation->andReturn($result); + } + + $this->app->instance(RegisterController::class, $mock); + } }