Add admin pages to manage invites

This commit is contained in:
DariusIII
2025-08-11 23:20:54 +02:00
parent 465c1b77b2
commit 498881f5a9
6 changed files with 1048 additions and 1 deletions
@@ -0,0 +1,273 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\BasePageController;
use App\Models\Invitation;
use App\Models\User;
use App\Services\InvitationService;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
class AdminInvitationController extends BasePageController
{
protected InvitationService $invitationService;
public function __construct(InvitationService $invitationService)
{
parent::__construct();
$this->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());
}
}
}
+1 -1
View File
@@ -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
@@ -0,0 +1,443 @@
<div class="card">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h4 class="mb-0">{$title}</h4>
<div class="d-flex gap-2">
<form method="POST" action="{{url("/admin/invitations/cleanup")}}" style="display: inline;">
{{csrf_field()}}
<button type="submit" class="btn btn-outline-warning btn-sm confirm_action"
data-message="Are you sure you want to cleanup all expired invitations?">
<i class="fa fa-broom me-1"></i>Cleanup Expired
</button>
</form>
</div>
</div>
</div>
<div class="card-body">
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-lg-3 col-md-6 mb-3">
<div class="card bg-primary text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h4 class="mb-0">{$stats.total}</h4>
<p class="mb-0">Total Invitations</p>
</div>
<div class="align-self-center">
<i class="fa fa-envelope fa-2x"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 mb-3">
<div class="card bg-warning text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h4 class="mb-0">{$stats.pending}</h4>
<p class="mb-0">Pending</p>
</div>
<div class="align-self-center">
<i class="fa fa-clock-o fa-2x"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 mb-3">
<div class="card bg-success text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h4 class="mb-0">{$stats.used}</h4>
<p class="mb-0">Used</p>
</div>
<div class="align-self-center">
<i class="fa fa-check fa-2x"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 mb-3">
<div class="card bg-danger text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h4 class="mb-0">{$stats.expired}</h4>
<p class="mb-0">Expired</p>
</div>
<div class="align-self-center">
<i class="fa fa-times fa-2x"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Time-based Statistics -->
<div class="row mb-4">
<div class="col-md-4">
<div class="card border-info">
<div class="card-body text-center">
<h5 class="card-title text-info">Today</h5>
<h3>{$stats.today}</h3>
<small class="text-muted">Invitations sent today</small>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-info">
<div class="card-body text-center">
<h5 class="card-title text-info">This Week</h5>
<h3>{$stats.this_week}</h3>
<small class="text-muted">Invitations sent this week</small>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-info">
<div class="card-body text-center">
<h5 class="card-title text-info">This Month</h5>
<h3>{$stats.this_month}</h3>
<small class="text-muted">Invitations sent this month</small>
</div>
</div>
</div>
</div>
<!-- Top Inviters -->
{if $topInviters|@count > 0}
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="fa fa-trophy me-2"></i>Top Inviters</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>User</th>
<th>Total Sent</th>
<th>Successful</th>
<th>Success Rate</th>
</tr>
</thead>
<tbody>
{foreach $topInviters as $inviter}
<tr>
<td>
<a href="{{url("/admin/user-edit?id={$inviter.id}")}}" class="text-decoration-none">
{$inviter.username}
</a>
</td>
<td>{$inviter.total_invitations}</td>
<td>{$inviter.successful_invitations}</td>
<td>
{if $inviter.total_invitations > 0}
{math equation="round((x/y)*100, 1)" x=$inviter.successful_invitations y=$inviter.total_invitations}%
{else}
0%
{/if}
</td>
</tr>
{/foreach}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{/if}
<!-- Filters -->
<form method="GET" action="{{url("/admin/invitations")}}" class="mb-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="fa fa-filter me-2"></i>Filters</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-3">
<label for="status" class="form-label">Status</label>
<select name="status" id="status" class="form-select">
{html_options options=$statusOptions selected=$status}
</select>
</div>
<div class="col-md-3">
<label for="invited_by" class="form-label">Invited By</label>
<input type="text" name="invited_by" id="invited_by" value="{$invited_by}"
class="form-control" placeholder="Username">
</div>
<div class="col-md-3">
<label for="email" class="form-label">Email</label>
<input type="text" name="email" id="email" value="{$email}"
class="form-control" placeholder="Email address">
</div>
<div class="col-md-3 d-flex align-items-end">
<button type="submit" class="btn btn-primary me-2">
<i class="fa fa-search me-1"></i>Filter
</button>
<a href="{{url("/admin/invitations")}}" class="btn btn-outline-secondary">
<i class="fa fa-times me-1"></i>Clear
</a>
</div>
</div>
</div>
</div>
</form>
<!-- Bulk Actions -->
<form method="POST" action="{{url("/admin/invitations/bulk")}}" id="bulkForm">
{{csrf_field()}}
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex align-items-center gap-2">
<select name="bulk_action" class="form-select" style="width: auto;">
<option value="">Bulk Actions</option>
<option value="cancel">Cancel Selected</option>
<option value="resend">Resend Selected</option>
<option value="delete">Delete Selected</option>
</select>
<button type="submit" class="btn btn-outline-primary btn-sm" id="bulkSubmit" disabled>
Apply
</button>
</div>
<div>
<small class="text-muted">
Showing {$invitations->firstItem()} to {$invitations->lastItem()} of {$invitations->total()} invitations
</small>
</div>
</div>
<!-- Invitations Table -->
{if $invitations->count() > 0}
<div class="table-responsive">
<table class="table table-striped table-hover align-middle">
<thead class="thead-light">
<tr>
<th style="width: 30px;">
<input type="checkbox" id="selectAll" class="form-check-input">
</th>
<th>Email</th>
<th>Invited By</th>
<th>Status</th>
<th>Created</th>
<th>Expires</th>
<th>Used By</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{foreach $invitations as $invitation}
<tr>
<td>
<input type="checkbox" name="invitation_ids[]" value="{$invitation->id}"
class="form-check-input invitation-checkbox">
</td>
<td>
<div class="d-flex align-items-center">
<i class="fa fa-envelope text-muted me-2"></i>
<span>{$invitation->email}</span>
</div>
</td>
<td>
{if $invitation->invitedBy}
<a href="{{url("/admin/user-edit?id={$invitation->invitedBy->id}")}}"
class="text-decoration-none">
{$invitation->invitedBy->username}
</a>
{else}
<span class="text-muted">Unknown</span>
{/if}
</td>
<td>
{if $invitation->used_at}
<span class="badge bg-success">Used</span>
{elseif $invitation->expires_at|strtotime < $smarty.now}
<span class="badge bg-danger">Expired</span>
{elseif !$invitation->is_active}
<span class="badge bg-secondary">Cancelled</span>
{else}
<span class="badge bg-warning text-dark">Pending</span>
{/if}
</td>
<td>
<small title="{$invitation->created_at}">
{$invitation->created_at|date_format:"%m/%d/%Y %H:%M"}
</small>
</td>
<td>
<small title="{$invitation->expires_at}"
class="{if $invitation->expires_at|strtotime < $smarty.now}text-danger{/if}">
{$invitation->expires_at|date_format:"%m/%d/%Y %H:%M"}
</small>
</td>
<td>
{if $invitation->usedBy}
<a href="{{url("/admin/user-edit?id={$invitation->usedBy->id}")}}"
class="text-decoration-none">
{$invitation->usedBy->username}
</a>
<br><small class="text-muted">{$invitation->used_at|date_format:"%m/%d/%Y %H:%M"}</small>
{else}
<span class="text-muted">-</span>
{/if}
</td>
<td>
<div class="btn-group btn-group-sm">
<a href="{{url("/admin/invitations/{$invitation->id}")}}"
class="btn btn-outline-info btn-sm" title="View Details">
<i class="fa fa-eye"></i>
</a>
{if !$invitation->used_at && $invitation->expires_at|strtotime >= $smarty.now && $invitation->is_active}
<form method="POST" action="{{url("/admin/invitations/{$invitation->id}/resend")}}" style="display: inline;">
{{csrf_field()}}
<button type="submit" class="btn btn-outline-warning btn-sm" title="Resend">
<i class="fa fa-repeat"></i>
</button>
</form>
<form method="POST" action="{{url("/admin/invitations/{$invitation->id}/cancel")}}" style="display: inline;">
{{csrf_field()}}
<button type="submit" class="btn btn-outline-danger btn-sm confirm_action"
title="Cancel" data-message="Are you sure you want to cancel this invitation?">
<i class="fa fa-times"></i>
</button>
</form>
{/if}
</div>
</td>
</tr>
{/foreach}
</tbody>
</table>
</div>
</form>
<!-- Pagination -->
{if $invitations->hasPages()}
<div class="d-flex justify-content-center mt-4">
{$invitations->onEachSide(5)->links()}
</div>
{/if}
{else}
<div class="alert alert-info">
<i class="fa fa-info-circle me-2"></i>No invitations found matching your criteria.
</div>
{/if}
</div>
</div>
<script>
{literal}
document.addEventListener('DOMContentLoaded', function() {
// Select all checkbox functionality
const selectAllCheckbox = document.getElementById('selectAll');
const invitationCheckboxes = document.querySelectorAll('.invitation-checkbox');
const bulkSubmit = document.getElementById('bulkSubmit');
const bulkAction = document.querySelector('select[name="bulk_action"]');
function updateBulkSubmitState() {
const selectedCheckboxes = document.querySelectorAll('.invitation-checkbox:checked');
const hasSelection = selectedCheckboxes.length > 0;
const hasAction = bulkAction.value !== '';
bulkSubmit.disabled = !(hasSelection && hasAction);
}
selectAllCheckbox.addEventListener('change', function() {
invitationCheckboxes.forEach(checkbox => {
checkbox.checked = this.checked;
});
updateBulkSubmitState();
});
invitationCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
const checkedCount = document.querySelectorAll('.invitation-checkbox:checked').length;
selectAllCheckbox.checked = checkedCount === invitationCheckboxes.length;
selectAllCheckbox.indeterminate = checkedCount > 0 && checkedCount < invitationCheckboxes.length;
updateBulkSubmitState();
});
});
bulkAction.addEventListener('change', updateBulkSubmitState);
// Confirmation dialogs
document.querySelectorAll('.confirm_action').forEach(function(element) {
element.addEventListener('click', function(e) {
const message = this.getAttribute('data-message') || 'Are you sure you want to perform this action?';
if (!confirm(message)) {
e.preventDefault();
return false;
}
});
});
// Bulk form confirmation
document.getElementById('bulkForm').addEventListener('submit', function(e) {
const selectedCount = document.querySelectorAll('.invitation-checkbox:checked').length;
const action = bulkAction.value;
if (selectedCount === 0) {
e.preventDefault();
alert('Please select at least one invitation.');
return false;
}
let message = `Are you sure you want to ${action} ${selectedCount} invitation(s)?`;
if (!confirm(message)) {
e.preventDefault();
return false;
}
});
});
{/literal}
</script>
<style>
{literal}
.card {
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
border-radius: 0.375rem;
}
.table th {
border-top: none;
font-weight: 600;
background-color: #f8f9fa;
}
.btn-group-sm > .btn {
padding: 0.25rem 0.5rem;
}
.badge {
font-size: 0.75em;
}
/* Custom checkbox styling */
.form-check-input:checked {
background-color: #0d6efd;
border-color: #0d6efd;
}
/* Responsive adjustments */
@media (max-width: 767.98px) {
.table-responsive {
font-size: 0.875rem;
}
.btn-group-sm > .btn {
padding: 0.125rem 0.25rem;
}
}
{/literal}
</style>
@@ -0,0 +1,319 @@
<div class="card">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h4 class="mb-0">{$title}</h4>
<a href="{{url("/admin/invitations")}}" class="btn btn-outline-secondary">
<i class="fa fa-arrow-left me-2"></i>Back to Invitations
</a>
</div>
</div>
<div class="card-body">
<div class="row">
<!-- Invitation Details -->
<div class="col-lg-8">
<div class="card mb-4">
<div class="card-header bg-light">
<h5 class="mb-0"><i class="fa fa-envelope me-2"></i>Invitation Details</h5>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-3">Email:</dt>
<dd class="col-sm-9">
<i class="fa fa-envelope text-muted me-2"></i>
{$invitation->email}
</dd>
<dt class="col-sm-3">Token:</dt>
<dd class="col-sm-9">
<code class="bg-light p-2 rounded">{$invitation->token}</code>
<button class="btn btn-sm btn-outline-secondary ms-2" onclick="copyToClipboard('{$invitation->token}')">
<i class="fa fa-copy"></i>
</button>
</dd>
<dt class="col-sm-3">Invitation URL:</dt>
<dd class="col-sm-9">
{assign var="inviteUrl" value="{url("/invitation/{$invitation->token}")}"}
<div class="input-group">
<input type="text" class="form-control" value="{$inviteUrl}" readonly id="inviteUrl">
<button class="btn btn-outline-secondary" type="button" onclick="copyToClipboard('{$inviteUrl}')">
<i class="fa fa-copy"></i>
</button>
</div>
</dd>
<dt class="col-sm-3">Status:</dt>
<dd class="col-sm-9">
{if $invitation->used_at}
<span class="badge bg-success fs-6">
<i class="fa fa-check me-1"></i>Used
</span>
{elseif $invitation->expires_at|strtotime < $smarty.now}
<span class="badge bg-danger fs-6">
<i class="fa fa-times me-1"></i>Expired
</span>
{elseif !$invitation->is_active}
<span class="badge bg-secondary fs-6">
<i class="fa fa-ban me-1"></i>Cancelled
</span>
{else}
<span class="badge bg-warning text-dark fs-6">
<i class="fa fa-clock-o me-1"></i>Pending
</span>
{/if}
</dd>
<dt class="col-sm-3">Created:</dt>
<dd class="col-sm-9">
<i class="fa fa-calendar text-muted me-2"></i>
{$invitation->created_at|date_format:"%B %d, %Y at %H:%M"}
</dd>
<dt class="col-sm-3">Expires:</dt>
<dd class="col-sm-9">
<i class="fa fa-calendar-times-o text-muted me-2"></i>
<span class="{if $invitation->expires_at|strtotime < $smarty.now}text-danger{/if}">
{$invitation->expires_at|date_format:"%B %d, %Y at %H:%M"}
</span>
{if $invitation->expires_at|strtotime < $smarty.now}
<small class="text-danger">(Expired)</small>
{/if}
</dd>
{if $invitation->used_at}
<dt class="col-sm-3">Used:</dt>
<dd class="col-sm-9">
<i class="fa fa-check-circle text-success me-2"></i>
{$invitation->used_at|date_format:"%B %d, %Y at %H:%M"}
</dd>
{/if}
{if $invitation->metadata && $invitation->metadata != '[]'}
<dt class="col-sm-3">Metadata:</dt>
<dd class="col-sm-9">
<pre class="bg-light p-2 rounded">{$invitation->metadata|json_encode:JSON_PRETTY_PRINT}</pre>
</dd>
{/if}
</dl>
</div>
</div>
</div>
<!-- User Information -->
<div class="col-lg-4">
<!-- Invited By -->
<div class="card mb-4">
<div class="card-header bg-light">
<h5 class="mb-0"><i class="fa fa-user me-2"></i>Invited By</h5>
</div>
<div class="card-body">
{if $invitation->invitedBy}
<div class="d-flex align-items-center mb-3">
<div class="me-3">
<i class="fa fa-user-circle fa-3x text-muted"></i>
</div>
<div>
<h6 class="mb-1">
<a href="{{url("/admin/user-edit?id={$invitation->invitedBy->id}")}}"
class="text-decoration-none">
{$invitation->invitedBy->username}
</a>
</h6>
<small class="text-muted">{$invitation->invitedBy->email}</small>
<br>
<small class="text-muted">
Role: <span class="badge bg-secondary">{$invitation->invitedBy->role->name}</span>
</small>
</div>
</div>
<div class="row text-center">
<div class="col-6">
<div class="border-end">
<strong class="d-block">{$invitation->invitedBy->invites}</strong>
<small class="text-muted">Remaining Invites</small>
</div>
</div>
<div class="col-6">
<strong class="d-block">{$invitation->invitedBy->invitation|@count}</strong>
<small class="text-muted">Total Sent</small>
</div>
</div>
{else}
<div class="text-center py-3">
<i class="fa fa-user-times fa-3x text-muted mb-2"></i>
<p class="text-muted mb-0">Inviter information not available</p>
</div>
{/if}
</div>
</div>
<!-- Used By -->
{if $invitation->usedBy}
<div class="card mb-4">
<div class="card-header bg-light">
<h5 class="mb-0"><i class="fa fa-user-check me-2"></i>Used By</h5>
</div>
<div class="card-body">
<div class="d-flex align-items-center mb-3">
<div class="me-3">
<i class="fa fa-user-circle fa-3x text-success"></i>
</div>
<div>
<h6 class="mb-1">
<a href="{{url("/admin/user-edit?id={$invitation->usedBy->id}")}}"
class="text-decoration-none">
{$invitation->usedBy->username}
</a>
</h6>
<small class="text-muted">{$invitation->usedBy->email}</small>
<br>
<small class="text-muted">
Role: <span class="badge bg-secondary">{$invitation->usedBy->role->name}</span>
</small>
</div>
</div>
<div class="row text-center">
<div class="col-6">
<div class="border-end">
<strong class="d-block">{$invitation->usedBy->grabs}</strong>
<small class="text-muted">Grabs</small>
</div>
</div>
<div class="col-6">
<strong class="d-block">
{if $invitation->usedBy->created_at}
{$invitation->usedBy->created_at|date_format:"%m/%d/%Y"}
{else}
-
{/if}
</strong>
<small class="text-muted">Joined</small>
</div>
</div>
</div>
</div>
{/if}
<!-- Actions -->
<div class="card">
<div class="card-header bg-light">
<h5 class="mb-0"><i class="fa fa-cogs me-2"></i>Actions</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
{if !$invitation->used_at && $invitation->expires_at|strtotime >= $smarty.now && $invitation->is_active}
<form method="POST" action="{{url("/admin/invitations/{$invitation->id}/resend")}}">
{{csrf_field()}}
<button type="submit" class="btn btn-warning w-100">
<i class="fa fa-repeat me-2"></i>Resend Invitation
</button>
</form>
<form method="POST" action="{{url("/admin/invitations/{$invitation->id}/cancel")}}">
{{csrf_field()}}
<button type="submit" class="btn btn-danger w-100 confirm_action"
data-message="Are you sure you want to cancel this invitation?">
<i class="fa fa-times me-2"></i>Cancel Invitation
</button>
</form>
{/if}
<a href="{{url("/invitation/{$invitation->token}")}}"
class="btn btn-outline-info w-100" target="_blank">
<i class="fa fa-external-link me-2"></i>View Public Page
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
{literal}
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(function() {
// Show success feedback
const toast = document.createElement('div');
toast.className = 'toast align-items-center text-white bg-success border-0 position-fixed';
toast.style.cssText = 'top: 20px; right: 20px; z-index: 9999;';
toast.setAttribute('role', 'alert');
toast.innerHTML = `
<div class="d-flex">
<div class="toast-body">
<i class="fa fa-check me-2"></i>Copied to clipboard!
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
`;
document.body.appendChild(toast);
const bsToast = new bootstrap.Toast(toast);
bsToast.show();
toast.addEventListener('hidden.bs.toast', function() {
document.body.removeChild(toast);
});
}).catch(function(err) {
console.error('Failed to copy text: ', err);
alert('Failed to copy to clipboard');
});
}
document.addEventListener('DOMContentLoaded', function() {
// Confirmation dialogs
document.querySelectorAll('.confirm_action').forEach(function(element) {
element.addEventListener('click', function(e) {
const message = this.getAttribute('data-message') || 'Are you sure you want to perform this action?';
if (!confirm(message)) {
e.preventDefault();
return false;
}
});
});
});
{/literal}
</script>
<style>
{literal}
.card {
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
border-radius: 0.375rem;
}
.badge.fs-6 {
font-size: 0.9rem !important;
}
.border-end {
border-right: 1px solid #dee2e6 !important;
}
code {
font-size: 0.875em;
word-break: break-all;
}
pre {
font-size: 0.8rem;
max-height: 200px;
overflow-y: auto;
}
/* Responsive adjustments */
@media (max-width: 767.98px) {
.row.text-center .col-6 {
margin-bottom: 1rem;
}
.border-end {
border-right: none !important;
border-bottom: 1px solid #dee2e6 !important;
padding-bottom: 0.5rem;
}
}
{/literal}
</style>
@@ -236,6 +236,9 @@
<a href="{{url("/admin/role-add")}}" class="list-group-item list-group-item-action bg-dark text-white">
<span class="menu-collapsed">Add User Roles</span>
</a>
<a href="{{url("/admin/invitations")}}" class="list-group-item list-group-item-action bg-dark text-white">
<span class="menu-collapsed">Manage Invitations</span>
</a>
<a href="{{url("/admin/deleted-users")}}" class="list-group-item list-group-item-action bg-dark text-white">
<span class="menu-collapsed">Deleted Users</span>
</a>
+9
View File
@@ -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');