From 498881f5a92a790141c2bf0ca1c80ac6846d2c8d Mon Sep 17 00:00:00 2001 From: DariusIII Date: Mon, 11 Aug 2025 23:20:54 +0200 Subject: [PATCH] Add admin pages to manage invites --- .../Admin/AdminInvitationController.php | 273 +++++++++++ app/Models/User.php | 2 +- .../themes/admin/admin-invitation-list.tpl | 443 ++++++++++++++++++ .../themes/admin/admin-invitation-show.tpl | 319 +++++++++++++ resources/views/themes/admin/adminmenu.tpl | 3 + routes/web.php | 9 + 6 files changed, 1048 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/Admin/AdminInvitationController.php create mode 100644 resources/views/themes/admin/admin-invitation-list.tpl create mode 100644 resources/views/themes/admin/admin-invitation-show.tpl diff --git a/app/Http/Controllers/Admin/AdminInvitationController.php b/app/Http/Controllers/Admin/AdminInvitationController.php new file mode 100644 index 000000000..6cf837b09 --- /dev/null +++ b/app/Http/Controllers/Admin/AdminInvitationController.php @@ -0,0 +1,273 @@ +invitationService = $invitationService; + } + + /** + * Display all invitations statistics and management page + */ + public function index(Request $request): void + { + $this->setAdminPrefs(); + + $meta_title = $title = 'Invitation Management'; + + // Get filter parameters + $status = $request->get('status', ''); + $invited_by = $request->get('invited_by', ''); + $email = $request->get('email', ''); + $orderBy = $request->get('ob', 'created_at_desc'); + $page = $request->get('page', 1); + + // Build query + $query = Invitation::with(['invitedBy', 'usedBy']); + + // Apply filters + if ($status) { + switch ($status) { + case 'pending': + $query->valid(); // Active, not expired, not used + break; + case 'used': + $query->used(); // Has used_at timestamp + break; + case 'expired': + $query->expired(); // expires_at is in the past + break; + case 'cancelled': + $query->where('is_active', false) + ->whereNull('used_at'); // Inactive but not used = cancelled + break; + } + } + + if ($invited_by) { + $user = User::where('username', 'like', '%' . $invited_by . '%')->first(); + if ($user) { + $query->where('invited_by', $user->id); + } + } + + if ($email) { + $query->where('email', 'like', '%' . $email . '%'); + } + + // Apply ordering + switch ($orderBy) { + case 'created_at_asc': + $query->orderBy('created_at', 'asc'); + break; + case 'created_at_desc': + $query->orderBy('created_at', 'desc'); + break; + case 'expires_at_asc': + $query->orderBy('expires_at', 'asc'); + break; + case 'expires_at_desc': + $query->orderBy('expires_at', 'desc'); + break; + case 'email_asc': + $query->orderBy('email', 'asc'); + break; + case 'email_desc': + $query->orderBy('email', 'desc'); + break; + default: + $query->orderBy('created_at', 'desc'); + } + + // Paginate results + $invitations = $query->paginate(config('nntmux.items_per_page', 25)); + + // Get overall statistics + $stats = $this->getOverallStats(); + + // Get user statistics (top inviters) + $topInviters = $this->getTopInviters(); + + $this->smarty->assign([ + 'invitations' => $invitations, + 'stats' => $stats, + 'topInviters' => $topInviters, + 'status' => $status, + 'invited_by' => $invited_by, + 'email' => $email, + 'orderBy' => $orderBy, + 'statusOptions' => [ + '' => 'All', + 'pending' => 'Pending', + 'used' => 'Used', + 'expired' => 'Expired', + 'cancelled' => 'Cancelled' + ] + ]); + + $content = $this->smarty->fetch('admin-invitation-list.tpl'); + $this->smarty->assign(compact('title', 'meta_title', 'content')); + + $this->adminrender(); + } + + /** + * Get overall invitation statistics + */ + private function getOverallStats(): array + { + return [ + 'total' => Invitation::count(), + 'pending' => Invitation::valid()->count(), + 'used' => Invitation::used()->count(), + 'expired' => Invitation::expired()->count(), + 'cancelled' => Invitation::where('is_active', false)->whereNull('used_at')->count(), + 'today' => Invitation::whereDate('created_at', today())->count(), + 'this_week' => Invitation::whereBetween('created_at', [ + now()->startOfWeek(), + now()->endOfWeek() + ])->count(), + 'this_month' => Invitation::whereMonth('created_at', now()->month) + ->whereYear('created_at', now()->year) + ->count(), + ]; + } + + /** + * Get top inviters statistics + */ + private function getTopInviters(int $limit = 10): array + { + return User::select('users.*') + ->selectRaw('COUNT(invitations.id) as total_invitations') + ->selectRaw('COUNT(CASE WHEN invitations.used_at IS NOT NULL THEN 1 END) as successful_invitations') + ->leftJoin('invitations', 'users.id', '=', 'invitations.invited_by') + ->groupBy('users.id') + ->having('total_invitations', '>', 0) + ->orderBy('total_invitations', 'desc') + ->limit($limit) + ->get() + ->toArray(); + } + + /** + * Cancel an invitation + */ + public function cancel(Request $request): RedirectResponse + { + try { + $id = $request->route('id'); + $this->invitationService->cancelInvitation($id); + + return redirect()->back()->with('success', 'Invitation cancelled successfully'); + } catch (\Exception $e) { + return redirect()->back()->with('error', 'Failed to cancel invitation: ' . $e->getMessage()); + } + } + + /** + * Resend an invitation + */ + public function resend(Request $request): RedirectResponse + { + try { + $id = $request->route('id'); + $this->invitationService->resendInvitation($id); + + return redirect()->back()->with('success', 'Invitation resent successfully'); + } catch (\Exception $e) { + return redirect()->back()->with('error', 'Failed to resend invitation: ' . $e->getMessage()); + } + } + + /** + * Cleanup expired invitations + */ + public function cleanup(Request $request): RedirectResponse + { + try { + $cleanedCount = $this->invitationService->cleanupExpiredInvitations(); + + return redirect()->back()->with('success', "Cleaned up {$cleanedCount} expired invitations"); + } catch (\Exception $e) { + return redirect()->back()->with('error', 'Failed to cleanup invitations: ' . $e->getMessage()); + } + } + + /** + * View detailed invitation + */ + public function show(Request $request): void + { + $this->setAdminPrefs(); + + $id = $request->route('id'); + $invitation = Invitation::with(['invitedBy', 'usedBy'])->findOrFail($id); + + $meta_title = $title = 'Invitation Details'; + + $this->smarty->assign([ + 'invitation' => $invitation, + ]); + + $content = $this->smarty->fetch('admin-invitation-show.tpl'); + $this->smarty->assign(compact('title', 'meta_title', 'content')); + + $this->adminrender(); + } + + /** + * Bulk actions on invitations + */ + public function bulkAction(Request $request): RedirectResponse + { + $action = $request->get('bulk_action'); + $invitationIds = $request->get('invitation_ids', []); + + if (empty($invitationIds)) { + return redirect()->back()->with('error', 'No invitations selected'); + } + + try { + $count = 0; + switch ($action) { + case 'cancel': + foreach ($invitationIds as $id) { + $this->invitationService->cancelInvitation($id); + $count++; + } + return redirect()->back()->with('success', "Cancelled {$count} invitations"); + + case 'resend': + foreach ($invitationIds as $id) { + $this->invitationService->resendInvitation($id); + $count++; + } + return redirect()->back()->with('success', "Resent {$count} invitations"); + + case 'delete': + Invitation::whereIn('id', $invitationIds)->delete(); + $count = count($invitationIds); + return redirect()->back()->with('success', "Deleted {$count} invitations"); + + default: + return redirect()->back()->with('error', 'Invalid bulk action'); + } + } catch (\Exception $e) { + return redirect()->back()->with('error', 'Bulk action failed: ' . $e->getMessage()); + } + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 2e6ee8966..2d403c704 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -225,7 +225,7 @@ class User extends Authenticatable public function invitation(): HasMany { - return $this->hasMany(Invitation::class, 'users_id'); + return $this->hasMany(Invitation::class, 'invited_by'); } public function failedRelease(): HasMany diff --git a/resources/views/themes/admin/admin-invitation-list.tpl b/resources/views/themes/admin/admin-invitation-list.tpl new file mode 100644 index 000000000..db413d38a --- /dev/null +++ b/resources/views/themes/admin/admin-invitation-list.tpl @@ -0,0 +1,443 @@ +
+
+
+

{$title}

+
+
+ {{csrf_field()}} + +
+
+
+
+ +
+ +
+
+
+
+
+
+

{$stats.total}

+

Total Invitations

+
+
+ +
+
+
+
+
+
+
+
+
+
+

{$stats.pending}

+

Pending

+
+
+ +
+
+
+
+
+
+
+
+
+
+

{$stats.used}

+

Used

+
+
+ +
+
+
+
+
+
+
+
+
+
+

{$stats.expired}

+

Expired

+
+
+ +
+
+
+
+
+
+ + +
+
+
+
+
Today
+

{$stats.today}

+ Invitations sent today +
+
+
+
+
+
+
This Week
+

{$stats.this_week}

+ Invitations sent this week +
+
+
+
+
+
+
This Month
+

{$stats.this_month}

+ Invitations sent this month +
+
+
+
+ + + {if $topInviters|@count > 0} +
+
+
+
+
Top Inviters
+
+
+
+ + + + + + + + + + + {foreach $topInviters as $inviter} + + + + + + + {/foreach} + +
UserTotal SentSuccessfulSuccess Rate
+ + {$inviter.username} + + {$inviter.total_invitations}{$inviter.successful_invitations} + {if $inviter.total_invitations > 0} + {math equation="round((x/y)*100, 1)" x=$inviter.successful_invitations y=$inviter.total_invitations}% + {else} + 0% + {/if} +
+
+
+
+
+
+ {/if} + + +
+
+
+
Filters
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + + Clear + +
+
+
+
+
+ + +
+ {{csrf_field()}} +
+
+ + +
+
+ + Showing {$invitations->firstItem()} to {$invitations->lastItem()} of {$invitations->total()} invitations + +
+
+ + + {if $invitations->count() > 0} +
+ + + + + + + + + + + + + + + {foreach $invitations as $invitation} + + + + + + + + + + + {/foreach} + +
+ + EmailInvited ByStatusCreatedExpiresUsed ByActions
+ + +
+ + {$invitation->email} +
+
+ {if $invitation->invitedBy} + invitedBy->id}")}}" + class="text-decoration-none"> + {$invitation->invitedBy->username} + + {else} + Unknown + {/if} + + {if $invitation->used_at} + Used + {elseif $invitation->expires_at|strtotime < $smarty.now} + Expired + {elseif !$invitation->is_active} + Cancelled + {else} + Pending + {/if} + + + {$invitation->created_at|date_format:"%m/%d/%Y %H:%M"} + + + + {$invitation->expires_at|date_format:"%m/%d/%Y %H:%M"} + + + {if $invitation->usedBy} + usedBy->id}")}}" + class="text-decoration-none"> + {$invitation->usedBy->username} + +
{$invitation->used_at|date_format:"%m/%d/%Y %H:%M"} + {else} + - + {/if} +
+
+ id}")}}" + class="btn btn-outline-info btn-sm" title="View Details"> + + + {if !$invitation->used_at && $invitation->expires_at|strtotime >= $smarty.now && $invitation->is_active} + id}/resend")}}" style="display: inline;"> + {{csrf_field()}} + + +
id}/cancel")}}" style="display: inline;"> + {{csrf_field()}} + +
+ {/if} +
+
+
+ + + + {if $invitations->hasPages()} +
+ {$invitations->onEachSide(5)->links()} +
+ {/if} + + {else} +
+ No invitations found matching your criteria. +
+ {/if} +
+
+ + + + diff --git a/resources/views/themes/admin/admin-invitation-show.tpl b/resources/views/themes/admin/admin-invitation-show.tpl new file mode 100644 index 000000000..471590402 --- /dev/null +++ b/resources/views/themes/admin/admin-invitation-show.tpl @@ -0,0 +1,319 @@ +
+
+ +
+ +
+
+ +
+
+
+
Invitation Details
+
+
+
+
Email:
+
+ + {$invitation->email} +
+ +
Token:
+
+ {$invitation->token} + +
+ +
Invitation URL:
+
+ {assign var="inviteUrl" value="{url("/invitation/{$invitation->token}")}"} +
+ + +
+
+ +
Status:
+
+ {if $invitation->used_at} + + Used + + {elseif $invitation->expires_at|strtotime < $smarty.now} + + Expired + + {elseif !$invitation->is_active} + + Cancelled + + {else} + + Pending + + {/if} +
+ +
Created:
+
+ + {$invitation->created_at|date_format:"%B %d, %Y at %H:%M"} +
+ +
Expires:
+
+ + + {$invitation->expires_at|date_format:"%B %d, %Y at %H:%M"} + + {if $invitation->expires_at|strtotime < $smarty.now} + (Expired) + {/if} +
+ + {if $invitation->used_at} +
Used:
+
+ + {$invitation->used_at|date_format:"%B %d, %Y at %H:%M"} +
+ {/if} + + {if $invitation->metadata && $invitation->metadata != '[]'} +
Metadata:
+
+
{$invitation->metadata|json_encode:JSON_PRETTY_PRINT}
+
+ {/if} +
+
+
+
+ + +
+ +
+
+
Invited By
+
+
+ {if $invitation->invitedBy} +
+
+ +
+
+
+ invitedBy->id}")}}" + class="text-decoration-none"> + {$invitation->invitedBy->username} + +
+ {$invitation->invitedBy->email} +
+ + Role: {$invitation->invitedBy->role->name} + +
+
+
+
+
+ {$invitation->invitedBy->invites} + Remaining Invites +
+
+
+ {$invitation->invitedBy->invitation|@count} + Total Sent +
+
+ {else} +
+ +

Inviter information not available

+
+ {/if} +
+
+ + + {if $invitation->usedBy} +
+
+
Used By
+
+
+
+
+ +
+
+
+ usedBy->id}")}}" + class="text-decoration-none"> + {$invitation->usedBy->username} + +
+ {$invitation->usedBy->email} +
+ + Role: {$invitation->usedBy->role->name} + +
+
+
+
+
+ {$invitation->usedBy->grabs} + Grabs +
+
+
+ + {if $invitation->usedBy->created_at} + {$invitation->usedBy->created_at|date_format:"%m/%d/%Y"} + {else} + - + {/if} + + Joined +
+
+
+
+ {/if} + + +
+
+
Actions
+
+
+
+ {if !$invitation->used_at && $invitation->expires_at|strtotime >= $smarty.now && $invitation->is_active} +
id}/resend")}}"> + {{csrf_field()}} + +
+ +
id}/cancel")}}"> + {{csrf_field()}} + +
+ {/if} + + token}")}}" + class="btn btn-outline-info w-100" target="_blank"> + View Public Page + +
+
+
+
+
+
+
+ + + + diff --git a/resources/views/themes/admin/adminmenu.tpl b/resources/views/themes/admin/adminmenu.tpl index 0440c3907..c0b46d282 100644 --- a/resources/views/themes/admin/adminmenu.tpl +++ b/resources/views/themes/admin/adminmenu.tpl @@ -236,6 +236,9 @@ Add User Roles + + Manage Invitations + Deleted Users diff --git a/routes/web.php b/routes/web.php index ea8fa5a81..33f234ba0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -242,4 +242,13 @@ Route::get('/invitation/{token}', [InvitationController::class, 'show'])->name(' // Admin invitation cleanup Route::post('/admin/invitations/cleanup', [InvitationController::class, 'cleanup'])->name('admin.invitations.cleanup')->middleware('role:Admin'); +// Admin invitation management routes +Route::middleware(['role:Admin'])->prefix('admin/invitations')->name('admin.invitations.')->group(function () { + Route::get('/', [App\Http\Controllers\Admin\AdminInvitationController::class, 'index'])->name('index'); + Route::get('/{id}', [App\Http\Controllers\Admin\AdminInvitationController::class, 'show'])->name('show'); + Route::post('/{id}/resend', [App\Http\Controllers\Admin\AdminInvitationController::class, 'resend'])->name('resend'); + Route::post('/{id}/cancel', [App\Http\Controllers\Admin\AdminInvitationController::class, 'cancel'])->name('cancel'); + Route::post('/bulk', [App\Http\Controllers\Admin\AdminInvitationController::class, 'bulkAction'])->name('bulk'); +}); + Route::post('btcpay/webhook', [BtcPaymentController::class, 'btcPayCallback'])->name('btcpay.webhook');