mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-29 01:08:56 +00:00
Add custom invitation system
This commit is contained in:
@@ -17,7 +17,6 @@ use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Jrean\UserVerification\Traits\VerifiesUsers;
|
||||
use Junaidnasir\Larainvite\Facades\Invite;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class RegisterController extends Controller
|
||||
@@ -121,6 +120,12 @@ class RegisterController extends Controller
|
||||
$this->inviteCodeQuery = '&invitecode='.$inviteCode;
|
||||
}
|
||||
|
||||
// Handle invitation token from URL (for email links)
|
||||
if ($request->has('token')) {
|
||||
$inviteCode = $request->input('token');
|
||||
$this->inviteCodeQuery = '&token='.$inviteCode;
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'username' => ['required', 'string', 'min:5', 'max:255', 'unique:users'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users', 'indisposable'],
|
||||
@@ -157,19 +162,51 @@ class RegisterController extends Controller
|
||||
return $this->showRegistrationForm($request, $error);
|
||||
}
|
||||
|
||||
if (Invite::isAllowed($inviteCode, $email) || Settings::settingValue('registerstatus') !== Settings::REGISTER_STATUS_INVITE) {
|
||||
$user = $this->create(
|
||||
[
|
||||
'username' => $userName,
|
||||
'password' => $password,
|
||||
'email' => $email,
|
||||
'host' => $request->ip(),
|
||||
'roles_id' => $userDefault !== null ? $userDefault['id'] : User::ROLE_USER,
|
||||
'notes' => '',
|
||||
'defaultinvites' => $userDefault !== null ? $userDefault['defaultinvites'] : Invitation::DEFAULT_INVITES,
|
||||
]
|
||||
);
|
||||
Invite::consume($inviteCode);
|
||||
// Check invitation validity using custom system
|
||||
$invitationValid = $this->isInvitationValid($inviteCode, $email);
|
||||
$registrationOpen = Settings::settingValue('registerstatus') !== Settings::REGISTER_STATUS_INVITE;
|
||||
|
||||
if ($invitationValid || $registrationOpen) {
|
||||
// Get invited_by from invitation if available
|
||||
$invitedBy = 0;
|
||||
$invitation = null;
|
||||
|
||||
if (!empty($inviteCode)) {
|
||||
$invitation = Invitation::findValidByToken($inviteCode);
|
||||
if ($invitation) {
|
||||
$invitedBy = $invitation->invited_by;
|
||||
|
||||
// Validate email matches invitation
|
||||
if (!empty($invitation->email) && $invitation->email !== $email) {
|
||||
$error = 'Email address does not match the invitation.';
|
||||
return $this->showRegistrationForm($request, $error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$userData = [
|
||||
'username' => $userName,
|
||||
'password' => $password,
|
||||
'email' => $email,
|
||||
'host' => $request->ip(),
|
||||
'roles_id' => $userDefault !== null ? $userDefault['id'] : User::ROLE_USER,
|
||||
'notes' => '',
|
||||
'defaultinvites' => $userDefault !== null ? $userDefault['defaultinvites'] : Invitation::DEFAULT_INVITES,
|
||||
];
|
||||
|
||||
// Apply invitation metadata if available
|
||||
if ($invitation && $invitation->metadata) {
|
||||
if (isset($invitation->metadata['role'])) {
|
||||
$userData['roles_id'] = $invitation->metadata['role'];
|
||||
}
|
||||
}
|
||||
|
||||
$user = $this->create($userData);
|
||||
|
||||
// Mark invitation as used
|
||||
if ($invitation) {
|
||||
$invitation->markAsUsed($user->id);
|
||||
}
|
||||
|
||||
// Create verification message that will be shown on login page
|
||||
$notificationMessage = 'Your Account has been created. A verification email has been sent to your email address. You will be able to log in after completing the verification process.';
|
||||
@@ -186,10 +223,13 @@ class RegisterController extends Controller
|
||||
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
$error = 'Invalid or expired invitation token!';
|
||||
break;
|
||||
|
||||
case 'view':
|
||||
// See if it is a valid invite.
|
||||
if (($inviteCode !== null) && ! Invite::isValid($inviteCode)) {
|
||||
if (($inviteCode !== null) && ! $this->isInvitationTokenValid($inviteCode)) {
|
||||
$error = 'Invalid invitation token!';
|
||||
$showRegister = 0;
|
||||
} else {
|
||||
@@ -197,6 +237,7 @@ class RegisterController extends Controller
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
app('smarty.view')->assign(
|
||||
[
|
||||
'username' => e($userName),
|
||||
@@ -220,11 +261,23 @@ class RegisterController extends Controller
|
||||
$this->inviteCodeQuery = '&invitecode='.$inviteCode;
|
||||
}
|
||||
|
||||
// Handle invitation token from URL (for email links)
|
||||
if ($request->has('token')) {
|
||||
$inviteCode = $request->input('token');
|
||||
$this->inviteCodeQuery = '&token='.$inviteCode;
|
||||
}
|
||||
|
||||
if ((int) Settings::settingValue('registerstatus') === Settings::REGISTER_STATUS_INVITE) {
|
||||
if (! empty($inviteCode)) {
|
||||
if (Invite::isValid($inviteCode)) {
|
||||
if ($this->isInvitationTokenValid($inviteCode)) {
|
||||
$error = '';
|
||||
$showRegister = 1;
|
||||
|
||||
// Pre-fill email if invitation has one
|
||||
$invitation = Invitation::findValidByToken($inviteCode);
|
||||
if ($invitation && !empty($invitation->email)) {
|
||||
app('smarty.view')->assign('email', $invitation->email);
|
||||
}
|
||||
} else {
|
||||
$error = 'Invalid or expired invitation token!';
|
||||
$showRegister = 0;
|
||||
@@ -236,7 +289,7 @@ class RegisterController extends Controller
|
||||
} elseif ((int) Settings::settingValue('registerstatus') === Settings::REGISTER_STATUS_CLOSED) {
|
||||
$error = 'Registrations are currently closed.';
|
||||
$showRegister = 0;
|
||||
} elseif ($request->has('invitecode')) {
|
||||
} elseif ($request->has('invitecode') || $request->has('token')) {
|
||||
$error = 'Registration is open, you don\'t need the invite code to register.';
|
||||
$showRegister = 0;
|
||||
} else {
|
||||
@@ -258,4 +311,40 @@ class RegisterController extends Controller
|
||||
app('smarty.view')->assign(compact('content', 'meta_title', 'meta_keywords', 'meta_description', 'nocaptcha'));
|
||||
app('smarty.view')->display($theme.'/basepage.tpl');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if invitation is valid for given email
|
||||
*/
|
||||
private function isInvitationValid(string $token, string $email): bool
|
||||
{
|
||||
if (empty($token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$invitation = Invitation::findValidByToken($token);
|
||||
|
||||
if (!$invitation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If invitation has specific email, validate it matches
|
||||
if (!empty($invitation->email) && $invitation->email !== $email) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if invitation token is valid (without email check)
|
||||
*/
|
||||
private function isInvitationTokenValid(string $token): bool
|
||||
{
|
||||
if (empty($token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$invitation = Invitation::findValidByToken($token);
|
||||
return $invitation !== null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Invitation;
|
||||
use App\Services\InvitationService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class InvitationController extends BasePageController
|
||||
{
|
||||
protected InvitationService $invitationService;
|
||||
|
||||
public function __construct(InvitationService $invitationService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->invitationService = $invitationService;
|
||||
$this->middleware('auth')->except(['show', 'accept']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a listing of the user's invitations
|
||||
*/
|
||||
public function index(Request $request): void
|
||||
{
|
||||
$this->setPreferences();
|
||||
|
||||
$status = $request->get('status');
|
||||
$user = auth()->user();
|
||||
|
||||
$invitations = $this->invitationService->getUserInvitations($user->id, $status);
|
||||
$stats = $this->invitationService->getUserInvitationStats($user->id);
|
||||
|
||||
// Convert paginated results to array for Smarty
|
||||
$invitationsArray = [];
|
||||
foreach ($invitations as $invitation) {
|
||||
$invitationData = $invitation->toArray();
|
||||
|
||||
// Add related user data
|
||||
if ($invitation->usedBy) {
|
||||
$invitationData['used_by_user'] = $invitation->usedBy->toArray();
|
||||
}
|
||||
|
||||
// Convert timestamps for Smarty
|
||||
$invitationData['created_at'] = strtotime($invitation->created_at);
|
||||
$invitationData['expires_at'] = strtotime($invitation->expires_at);
|
||||
if ($invitation->used_at) {
|
||||
$invitationData['used_at'] = strtotime($invitation->used_at);
|
||||
}
|
||||
|
||||
$invitationsArray[] = $invitationData;
|
||||
}
|
||||
|
||||
$this->smarty->assign([
|
||||
'invitations' => $invitationsArray,
|
||||
'stats' => $stats,
|
||||
'status' => $status,
|
||||
'pagination_links' => $invitations->links(),
|
||||
'csrf_token' => csrf_token(),
|
||||
]);
|
||||
|
||||
// Set meta information
|
||||
$meta_title = 'My Invitations';
|
||||
$meta_keywords = 'invitations,invite,users,manage';
|
||||
$meta_description = 'Manage your sent invitations and send new invitations to friends';
|
||||
|
||||
// Fetch the template content
|
||||
$content = $this->smarty->fetch('invitations_index.tpl');
|
||||
|
||||
// Assign content and meta data for final rendering
|
||||
$this->smarty->assign([
|
||||
'content' => $content,
|
||||
'meta_title' => $meta_title,
|
||||
'meta_keywords' => $meta_keywords,
|
||||
'meta_description' => $meta_description,
|
||||
]);
|
||||
|
||||
// Render the page with proper styling
|
||||
$this->pagerender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new invitation
|
||||
*/
|
||||
public function create(): void
|
||||
{
|
||||
$this->setPreferences();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
// Calculate available invites (total - active pending invitations)
|
||||
$activeInvitations = Invitation::where('invited_by', $user->id)
|
||||
->where('is_active', true)
|
||||
->where('used_at', null)
|
||||
->where('expires_at', '>', now())
|
||||
->count();
|
||||
|
||||
$availableInvites = $user->invites - $activeInvitations;
|
||||
|
||||
$this->smarty->assign([
|
||||
'user_roles' => config('nntmux.user_roles', []),
|
||||
'csrf_token' => csrf_token(),
|
||||
'old' => old(),
|
||||
'errors' => session('errors') ? session('errors')->getBag('default')->getMessages() : [],
|
||||
'user_invites_left' => $availableInvites,
|
||||
'user_invites_total' => $user->invites,
|
||||
'user_invites_pending' => $activeInvitations,
|
||||
'can_send_invites' => $availableInvites > 0,
|
||||
]);
|
||||
|
||||
// Set meta information
|
||||
$meta_title = 'Send New Invitation';
|
||||
$meta_keywords = 'invitation,invite,send,new,user';
|
||||
$meta_description = 'Send a new invitation to invite someone to join the site';
|
||||
|
||||
// Fetch the template content
|
||||
$content = $this->smarty->fetch('invitations_create.tpl');
|
||||
|
||||
// Assign content and meta data for final rendering
|
||||
$this->smarty->assign([
|
||||
'content' => $content,
|
||||
'meta_title' => $meta_title,
|
||||
'meta_keywords' => $meta_keywords,
|
||||
'meta_description' => $meta_description,
|
||||
]);
|
||||
|
||||
// Render the page with proper styling
|
||||
$this->pagerender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created invitation
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'email' => 'required|email|unique:users,email',
|
||||
'expiry_days' => 'sometimes|integer|min:1|max:30',
|
||||
'role' => 'sometimes|integer|in:' . implode(',', array_keys(config('nntmux.user_roles', []))),
|
||||
]);
|
||||
|
||||
try {
|
||||
$expiryDays = $request->get('expiry_days', Invitation::DEFAULT_INVITE_EXPIRY_DAYS);
|
||||
$metadata = [];
|
||||
|
||||
if ($request->has('role')) {
|
||||
$metadata['role'] = $request->get('role');
|
||||
}
|
||||
|
||||
$invitation = $this->invitationService->createAndSendInvitation(
|
||||
$request->email,
|
||||
auth()->id(),
|
||||
$expiryDays,
|
||||
$metadata
|
||||
);
|
||||
|
||||
return redirect()->route('invitations.index')
|
||||
->with('success', 'Invitation sent successfully to ' . $request->email);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->back()
|
||||
->withInput()
|
||||
->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified invitation
|
||||
*/
|
||||
public function show(string $token): void
|
||||
{
|
||||
// For public invitation page, we need to handle both logged in and guest users
|
||||
if (auth()->check()) {
|
||||
$this->setPreferences();
|
||||
} else {
|
||||
// Set up basic Smarty configuration for guests
|
||||
$this->smarty->setTemplateDir([
|
||||
'user' => config('ytake-laravel-smarty.template_path').'/Gentele',
|
||||
'shared' => config('ytake-laravel-smarty.template_path').'/shared',
|
||||
'default' => config('ytake-laravel-smarty.template_path').'/Gentele',
|
||||
]);
|
||||
|
||||
$this->smarty->assign([
|
||||
'isadmin' => false,
|
||||
'ismod' => false,
|
||||
'loggedin' => false,
|
||||
'theme' => 'Gentele',
|
||||
'site' => $this->settings,
|
||||
]);
|
||||
}
|
||||
|
||||
$preview = $this->invitationService->getInvitationPreview($token);
|
||||
|
||||
// Convert timestamps for Smarty
|
||||
if ($preview && isset($preview['expires_at'])) {
|
||||
$preview['expires_at'] = strtotime($preview['expires_at']);
|
||||
}
|
||||
|
||||
// Add role name if role is set
|
||||
if ($preview && isset($preview['metadata']['role'])) {
|
||||
$roles = config('nntmux.user_roles', []);
|
||||
$preview['role_name'] = $roles[$preview['metadata']['role']] ?? 'Default';
|
||||
}
|
||||
|
||||
$this->smarty->assign([
|
||||
'preview' => $preview,
|
||||
'token' => $token,
|
||||
]);
|
||||
|
||||
// Set meta information
|
||||
$meta_title = $preview ? 'Invitation to Join' : 'Invalid Invitation';
|
||||
$meta_keywords = 'invitation,join,register,signup';
|
||||
$meta_description = $preview ? 'You have been invited to join our community' : 'This invitation link is invalid or expired';
|
||||
|
||||
// Fetch the template content
|
||||
$content = $this->smarty->fetch('invitations_show.tpl');
|
||||
|
||||
// Assign content and meta data for final rendering
|
||||
$this->smarty->assign([
|
||||
'content' => $content,
|
||||
'meta_title' => $meta_title,
|
||||
'meta_keywords' => $meta_keywords,
|
||||
'meta_description' => $meta_description,
|
||||
]);
|
||||
|
||||
// Render the page with proper styling
|
||||
$this->pagerender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend an invitation
|
||||
*/
|
||||
public function resend(int $id): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$invitation = Invitation::findOrFail($id);
|
||||
|
||||
// Check if user owns this invitation
|
||||
if ($invitation->invited_by !== auth()->id()) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$this->invitationService->resendInvitation($id);
|
||||
|
||||
return redirect()->route('invitations.index')
|
||||
->with('success', 'Invitation resent successfully');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->back()
|
||||
->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an invitation
|
||||
*/
|
||||
public function destroy(int $id): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$invitation = Invitation::findOrFail($id);
|
||||
|
||||
// Check if user owns this invitation
|
||||
if ($invitation->invited_by !== auth()->id()) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$this->invitationService->cancelInvitation($id);
|
||||
|
||||
return redirect()->route('invitations.index')
|
||||
->with('success', 'Invitation cancelled successfully');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->back()
|
||||
->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invitation statistics (API endpoint)
|
||||
*/
|
||||
public function stats(): JsonResponse
|
||||
{
|
||||
$stats = $this->invitationService->getUserInvitationStats(auth()->id());
|
||||
return response()->json($stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired invitations (admin only)
|
||||
*/
|
||||
public function cleanup(): JsonResponse
|
||||
{
|
||||
// Check if user is admin
|
||||
if (!auth()->user()->hasRole('admin')) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$cleanedCount = $this->invitationService->cleanupExpiredInvitations();
|
||||
|
||||
return response()->json([
|
||||
'message' => "Cleaned up {$cleanedCount} expired invitations",
|
||||
'count' => $cleanedCount
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Invitation;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class InvitationMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public Invitation $invitation;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct(Invitation $invitation)
|
||||
{
|
||||
$this->invitation = $invitation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message envelope.
|
||||
*/
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'You\'re invited to join ' . config('app.name'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message content definition.
|
||||
*/
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.invitation',
|
||||
with: [
|
||||
'invitation' => $this->invitation,
|
||||
'invitedBy' => $this->invitation->invitedBy,
|
||||
'registerUrl' => route('register', ['token' => $this->invitation->token]),
|
||||
'expiresAt' => $this->invitation->expires_at,
|
||||
'siteName' => config('app.name'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attachments for the message.
|
||||
*
|
||||
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
+247
-42
@@ -2,77 +2,282 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* App\Models\Invitation.
|
||||
* App\Models\Invitation
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $guid
|
||||
* @property int $users_id
|
||||
* @property string|null $created_at
|
||||
* @property string|null $updated_at
|
||||
* @property-read User $user
|
||||
*
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Invitation whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Invitation whereGuid($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Invitation whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Invitation whereUpdatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Invitation whereUsersId($value)
|
||||
*
|
||||
* @mixin \Eloquent
|
||||
* @property string $token
|
||||
* @property string $email
|
||||
* @property int $invited_by
|
||||
* @property \Illuminate\Support\Carbon $expires_at
|
||||
* @property \Illuminate\Support\Carbon|null $used_at
|
||||
* @property int|null $used_by
|
||||
* @property bool $is_active
|
||||
* @property array|null $metadata
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \App\Models\User $invitedBy
|
||||
* @property-read \App\Models\User|null $usedBy
|
||||
*
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Invitation newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Invitation newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Invitation query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Invitation active()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Invitation valid()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Invitation expired()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Invitation unused()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Invitation used()
|
||||
*/
|
||||
class Invitation extends Model
|
||||
{
|
||||
public const DEFAULT_INVITES = 1;
|
||||
|
||||
public const DEFAULT_INVITE_EXPIRY_DAYS = 7;
|
||||
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = true;
|
||||
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
protected $dateFormat = false;
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected $guarded = ['id'];
|
||||
protected $fillable = [
|
||||
'token',
|
||||
'email',
|
||||
'invited_by',
|
||||
'expires_at',
|
||||
'used_at',
|
||||
'used_by',
|
||||
'is_active',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'users_id');
|
||||
}
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected $casts = [
|
||||
'expires_at' => 'datetime',
|
||||
'used_at' => 'datetime',
|
||||
'is_active' => 'boolean',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
public static function addInvite(int $uid, string $inviteToken)
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected $dates = [
|
||||
'expires_at',
|
||||
'used_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the user who created this invitation
|
||||
*/
|
||||
public function invitedBy(): BelongsTo
|
||||
{
|
||||
self::create(['guid' => $inviteToken, 'users_id' => $uid]);
|
||||
return $this->belongsTo(User::class, 'invited_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Model|null|static
|
||||
* Get the user who used this invitation
|
||||
*/
|
||||
public static function getInvite($inviteToken)
|
||||
public function usedBy(): BelongsTo
|
||||
{
|
||||
//
|
||||
// Tidy any old invites sent greater than DEFAULT_INVITE_EXPIRY_DAYS days ago.
|
||||
//
|
||||
self::query()->where('created_at', '<', now()->subDays(self::DEFAULT_INVITE_EXPIRY_DAYS));
|
||||
|
||||
return self::query()->where('guid', $inviteToken)->first();
|
||||
return $this->belongsTo(User::class, 'used_by');
|
||||
}
|
||||
|
||||
public static function deleteInvite(string $inviteToken): void
|
||||
/**
|
||||
* Scope to get only active invitations
|
||||
*/
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
self::query()->where('guid', $inviteToken)->delete();
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to get valid (active and not expired) invitations
|
||||
*/
|
||||
public function scopeValid(Builder $query): Builder
|
||||
{
|
||||
return $query->active()
|
||||
->where('expires_at', '>', now())
|
||||
->whereNull('used_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to get expired invitations
|
||||
*/
|
||||
public function scopeExpired(Builder $query): Builder
|
||||
{
|
||||
return $query->where('expires_at', '<=', now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to get unused invitations
|
||||
*/
|
||||
public function scopeUnused(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('used_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to get used invitations
|
||||
*/
|
||||
public function scopeUsed(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNotNull('used_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the invitation is valid
|
||||
*/
|
||||
public function isValid(): bool
|
||||
{
|
||||
return $this->is_active &&
|
||||
$this->expires_at->isFuture() &&
|
||||
is_null($this->used_at);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the invitation is expired
|
||||
*/
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->expires_at->isPast();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the invitation has been used
|
||||
*/
|
||||
public function isUsed(): bool
|
||||
{
|
||||
return !is_null($this->used_at);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the invitation as used
|
||||
*/
|
||||
public function markAsUsed(int $userId): bool
|
||||
{
|
||||
$this->used_at = now();
|
||||
$this->used_by = $userId;
|
||||
$this->is_active = false;
|
||||
|
||||
return $this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the invitation as expired
|
||||
*/
|
||||
public function markAsExpired(): bool
|
||||
{
|
||||
$this->is_active = false;
|
||||
return $this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new invitation
|
||||
*/
|
||||
public static function createInvitation(
|
||||
string $email,
|
||||
int $invitedBy,
|
||||
int $expiryDays = self::DEFAULT_INVITE_EXPIRY_DAYS,
|
||||
array $metadata = []
|
||||
): self {
|
||||
return self::create([
|
||||
'token' => Str::random(64),
|
||||
'email' => strtolower(trim($email)),
|
||||
'invited_by' => $invitedBy,
|
||||
'expires_at' => now()->addDays($expiryDays),
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find invitation by token
|
||||
*/
|
||||
public static function findByToken(string $token): ?self
|
||||
{
|
||||
return self::where('token', $token)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find valid invitation by token
|
||||
*/
|
||||
public static function findValidByToken(string $token): ?self
|
||||
{
|
||||
return self::valid()->where('token', $token)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find invitation by email
|
||||
*/
|
||||
public static function findByEmail(string $email): ?self
|
||||
{
|
||||
return self::where('email', strtolower(trim($email)))->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invitation by token (legacy compatibility)
|
||||
*/
|
||||
public static function getInvite(string $token): ?self
|
||||
{
|
||||
return self::findValidByToken($token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete invitation by token (legacy compatibility)
|
||||
*/
|
||||
public static function deleteInvite(string $token): bool
|
||||
{
|
||||
$invitation = self::findByToken($token);
|
||||
if ($invitation) {
|
||||
return $invitation->delete();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add invite (legacy compatibility)
|
||||
*/
|
||||
public static function addInvite(int $userId, string $token): self
|
||||
{
|
||||
// For legacy compatibility, we create an invitation with a specific token
|
||||
// In practice, this should use createInvitation method instead
|
||||
return self::create([
|
||||
'token' => $token,
|
||||
'email' => '', // Legacy method doesn't specify email
|
||||
'invited_by' => $userId,
|
||||
'expires_at' => now()->addDays(self::DEFAULT_INVITE_EXPIRY_DAYS),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired invitations
|
||||
*/
|
||||
public static function cleanupExpired(): int
|
||||
{
|
||||
$expiredCount = self::expired()->active()->count();
|
||||
self::expired()->active()->update(['is_active' => false]);
|
||||
|
||||
return $expiredCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invitation statistics
|
||||
*/
|
||||
public static function getStats(): array
|
||||
{
|
||||
return [
|
||||
'total' => self::count(),
|
||||
'active' => self::active()->count(),
|
||||
'valid' => self::valid()->count(),
|
||||
'expired' => self::expired()->count(),
|
||||
'used' => self::used()->count(),
|
||||
'unused' => self::unused()->count(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
+12
-12
@@ -4,7 +4,6 @@ namespace App\Models;
|
||||
|
||||
use App\Jobs\SendAccountExpiredEmail;
|
||||
use App\Jobs\SendAccountWillExpireEmail;
|
||||
use App\Jobs\SendInviteEmail;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
@@ -24,8 +23,6 @@ use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Jrean\UserVerification\Traits\UserVerification;
|
||||
use Junaidnasir\Larainvite\Facades\Invite;
|
||||
use Junaidnasir\Larainvite\InviteTrait;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
@@ -148,7 +145,7 @@ use Spatie\Permission\Traits\HasRoles;
|
||||
*/
|
||||
class User extends Authenticatable
|
||||
{
|
||||
use HasRoles, InviteTrait, Notifiable, SoftDeletes, UserVerification;
|
||||
use HasRoles, Notifiable, SoftDeletes, UserVerification;
|
||||
|
||||
public const ERR_SIGNUP_BADUNAME = -1;
|
||||
|
||||
@@ -573,15 +570,15 @@ class User extends Authenticatable
|
||||
*/
|
||||
public static function checkAndUseInvite(string $inviteCode): int
|
||||
{
|
||||
$invite = Invitation::getInvite($inviteCode);
|
||||
$invite = Invitation::findValidByToken($inviteCode);
|
||||
if (! $invite) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
self::query()->where('id', $invite['users_id'])->decrement('invites');
|
||||
Invitation::deleteInvite($inviteCode);
|
||||
self::query()->where('id', $invite->invited_by)->decrement('invites');
|
||||
$invite->markAsUsed(0); // Will be updated with actual user ID later
|
||||
|
||||
return $invite['users_id'];
|
||||
return $invite->invited_by;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -669,11 +666,14 @@ class User extends Authenticatable
|
||||
public static function sendInvite($serverUrl, $uid, $emailTo): string
|
||||
{
|
||||
$user = self::find($uid);
|
||||
$token = Invite::invite($emailTo, $user->id);
|
||||
$url = $serverUrl.'/register?invitecode='.$token;
|
||||
|
||||
Invitation::addInvite($uid, $token);
|
||||
SendInviteEmail::dispatch($emailTo, $user, $url)->onQueue('emails');
|
||||
// Create invitation using our custom system
|
||||
$invitation = Invitation::createInvitation($emailTo, $user->id);
|
||||
$url = $serverUrl.'/register?token='.$invitation->token;
|
||||
|
||||
// Send invitation email
|
||||
$invitationService = app(InvitationService::class);
|
||||
$invitationService->sendInvitationEmail($invitation);
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Invitation;
|
||||
use App\Models\User;
|
||||
use App\Mail\InvitationMail;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class InvitationService
|
||||
{
|
||||
/**
|
||||
* Create and send an invitation
|
||||
*/
|
||||
public function createAndSendInvitation(
|
||||
string $email,
|
||||
int $invitedBy,
|
||||
int $expiryDays = Invitation::DEFAULT_INVITE_EXPIRY_DAYS,
|
||||
array $metadata = []
|
||||
): Invitation {
|
||||
// Get the user sending the invitation
|
||||
$user = User::find($invitedBy);
|
||||
if (!$user) {
|
||||
throw new \Exception('User not found.');
|
||||
}
|
||||
|
||||
// Calculate how many invites are currently "in use" (pending/active)
|
||||
$activeInvitations = Invitation::where('invited_by', $invitedBy)
|
||||
->where('is_active', true)
|
||||
->where('used_at', null)
|
||||
->where('expires_at', '>', now())
|
||||
->count();
|
||||
|
||||
// Check if user has invites available (total invites - active pending invitations)
|
||||
$availableInvites = $user->invites - $activeInvitations;
|
||||
if ($availableInvites <= 0) {
|
||||
throw new \Exception('You have no invitations available. You have ' . $activeInvitations . ' pending invitation(s). Contact an administrator if you need more invitations.');
|
||||
}
|
||||
|
||||
// Validate email
|
||||
$validator = Validator::make(['email' => $email], [
|
||||
'email' => 'required|email|unique:users,email'
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
throw new ValidationException($validator);
|
||||
}
|
||||
|
||||
// Check if there's already a valid invitation for this email
|
||||
$existingInvitation = Invitation::where('email', strtolower(trim($email)))
|
||||
->valid()
|
||||
->first();
|
||||
|
||||
if ($existingInvitation) {
|
||||
throw new \Exception('A valid invitation already exists for this email address.');
|
||||
}
|
||||
|
||||
// Create the invitation (don't decrement invites here - only when used)
|
||||
$invitation = Invitation::createInvitation($email, $invitedBy, $expiryDays, $metadata);
|
||||
|
||||
// Send the invitation email
|
||||
$this->sendInvitationEmail($invitation);
|
||||
|
||||
return $invitation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send invitation email
|
||||
*/
|
||||
public function sendInvitationEmail(Invitation $invitation): void
|
||||
{
|
||||
Mail::to($invitation->email)->send(new InvitationMail($invitation));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend an invitation
|
||||
*/
|
||||
public function resendInvitation(int $invitationId): Invitation
|
||||
{
|
||||
$invitation = Invitation::findOrFail($invitationId);
|
||||
|
||||
if (!$invitation->isValid()) {
|
||||
throw new \Exception('Cannot resend an invalid invitation.');
|
||||
}
|
||||
|
||||
$this->sendInvitationEmail($invitation);
|
||||
|
||||
return $invitation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an invitation
|
||||
*/
|
||||
public function cancelInvitation(int $invitationId): bool
|
||||
{
|
||||
$invitation = Invitation::findOrFail($invitationId);
|
||||
|
||||
if ($invitation->isUsed()) {
|
||||
throw new \Exception('Cannot cancel a used invitation.');
|
||||
}
|
||||
|
||||
$invitation->is_active = false;
|
||||
return $invitation->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept an invitation (use it for registration)
|
||||
*/
|
||||
public function acceptInvitation(string $token, array $userData): User
|
||||
{
|
||||
$invitation = Invitation::findValidByToken($token);
|
||||
|
||||
if (!$invitation) {
|
||||
throw new \Exception('Invalid or expired invitation token.');
|
||||
}
|
||||
|
||||
// Get the user who sent the invitation
|
||||
$inviter = User::find($invitation->invited_by);
|
||||
|
||||
// Create the user
|
||||
$user = User::create(array_merge($userData, [
|
||||
'email' => $invitation->email,
|
||||
'invited_by' => $invitation->invited_by,
|
||||
]));
|
||||
|
||||
// Mark invitation as used
|
||||
$invitation->markAsUsed($user->id);
|
||||
|
||||
// Now decrement the inviter's available invites (only when actually used)
|
||||
if ($inviter) {
|
||||
$inviter->decrement('invites');
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invitation statistics for a user
|
||||
*/
|
||||
public function getUserInvitationStats(int $userId): array
|
||||
{
|
||||
return [
|
||||
'sent' => Invitation::where('invited_by', $userId)->count(),
|
||||
'pending' => Invitation::where('invited_by', $userId)->valid()->count(),
|
||||
'used' => Invitation::where('invited_by', $userId)->used()->count(),
|
||||
'expired' => Invitation::where('invited_by', $userId)->expired()->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all invitations for a user
|
||||
*/
|
||||
public function getUserInvitations(int $userId, ?string $status = null)
|
||||
{
|
||||
$query = Invitation::where('invited_by', $userId)
|
||||
->with(['usedBy'])
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
return match($status) {
|
||||
'valid' => $query->valid()->paginate(15),
|
||||
'used' => $query->used()->paginate(15),
|
||||
'expired' => $query->expired()->paginate(15),
|
||||
'pending' => $query->unused()->paginate(15),
|
||||
default => $query->paginate(15),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired invitations
|
||||
*/
|
||||
public function cleanupExpiredInvitations(): int
|
||||
{
|
||||
return Invitation::cleanupExpired();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user can send more invitations
|
||||
*/
|
||||
public function canUserSendInvitation(int $userId, int $maxInvitations = null): bool
|
||||
{
|
||||
if ($maxInvitations === null) {
|
||||
return true; // No limit set
|
||||
}
|
||||
|
||||
$sentCount = Invitation::where('invited_by', $userId)->count();
|
||||
return $sentCount < $maxInvitations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invitation by token for preview
|
||||
*/
|
||||
public function getInvitationPreview(string $token): ?array
|
||||
{
|
||||
$invitation = Invitation::findByToken($token);
|
||||
|
||||
if (!$invitation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'email' => $invitation->email,
|
||||
'invited_by' => $invitation->invitedBy->username ?? 'Unknown',
|
||||
'expires_at' => $invitation->expires_at,
|
||||
'is_valid' => $invitation->isValid(),
|
||||
'is_expired' => $invitation->isExpired(),
|
||||
'is_used' => $invitation->isUsed(),
|
||||
'metadata' => $invitation->metadata,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed invitation information for a user (for debugging)
|
||||
*/
|
||||
public function getUserInvitationDetails(int $userId): array
|
||||
{
|
||||
$user = User::find($userId);
|
||||
if (!$user) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$totalInvites = $user->invites;
|
||||
|
||||
$activeInvitations = Invitation::where('invited_by', $userId)
|
||||
->where('is_active', true)
|
||||
->where('used_at', null)
|
||||
->where('expires_at', '>', now())
|
||||
->count();
|
||||
|
||||
$usedInvitations = Invitation::where('invited_by', $userId)
|
||||
->whereNotNull('used_at')
|
||||
->count();
|
||||
|
||||
$expiredInvitations = Invitation::where('invited_by', $userId)
|
||||
->where('expires_at', '<=', now())
|
||||
->where('used_at', null)
|
||||
->count();
|
||||
|
||||
$cancelledInvitations = Invitation::where('invited_by', $userId)
|
||||
->where('is_active', false)
|
||||
->where('used_at', null)
|
||||
->count();
|
||||
|
||||
$availableInvites = $totalInvites - $activeInvitations;
|
||||
|
||||
return [
|
||||
'user_id' => $userId,
|
||||
'username' => $user->username,
|
||||
'total_invites' => $totalInvites,
|
||||
'active_pending' => $activeInvitations,
|
||||
'used_invitations' => $usedInvitations,
|
||||
'expired_invitations' => $expiredInvitations,
|
||||
'cancelled_invitations' => $cancelledInvitations,
|
||||
'calculated_available' => $availableInvites,
|
||||
'can_send_invite' => $availableInvites > 0
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,6 @@
|
||||
"imdbphp/imdbphp": "^8.3",
|
||||
"intervention/image": "^3.11",
|
||||
"jrean/laravel-user-verification": "^13.0",
|
||||
"junaidnasir/larainvite": "^12.0",
|
||||
"kevinlebrun/colors.php": "^1.0",
|
||||
"laravel/framework": "^12.4",
|
||||
"laravel/horizon": "^5.31",
|
||||
|
||||
Generated
+1
-63
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "25da8443105cf65d7065ce5fb0fc8604",
|
||||
"content-hash": "10251ecba0052663a759a5be58b311d9",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aharen/omdbapi",
|
||||
@@ -3188,68 +3188,6 @@
|
||||
},
|
||||
"time": "2025-03-25T16:26:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "junaidnasir/larainvite",
|
||||
"version": "v12.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/junaidnasir/larainvite.git",
|
||||
"reference": "b39adf46b6c0202c2da038400acb8fe8989e7592"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/junaidnasir/larainvite/zipball/b39adf46b6c0202c2da038400acb8fe8989e7592",
|
||||
"reference": "b39adf46b6c0202c2da038400acb8fe8989e7592",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"illuminate/database": "^5.8|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
|
||||
"illuminate/events": "^5.8|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
|
||||
"illuminate/support": "^5.8|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
|
||||
"php": ">=5.5.9"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^5.2|^7.0|^8.0|^9.0|^10.5"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"Invite": "Junaidnasir\\Larainvite\\Facades\\Invite"
|
||||
},
|
||||
"providers": [
|
||||
"Junaidnasir\\Larainvite\\LaraInviteServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Junaidnasir\\Larainvite\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Junaid Nasir",
|
||||
"email": "contact@junaidnasir.com"
|
||||
}
|
||||
],
|
||||
"description": "Laravel Invitation package, existing users can invite others by email",
|
||||
"homepage": "https://github.com/junaidnasir/larainvite",
|
||||
"keywords": [
|
||||
"Invite",
|
||||
"invitations",
|
||||
"laravel"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/junaidnasir/larainvite/issues",
|
||||
"source": "https://github.com/junaidnasir/larainvite/tree/v12.0.0"
|
||||
},
|
||||
"time": "2025-03-12T23:26:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "kalnoy/nestedset",
|
||||
"version": "v6.0.6",
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Invitation Expiration Default
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Default Expiry time in Hours from current time.
|
||||
| i.e now() + expires (hours)
|
||||
|
|
||||
*/
|
||||
'expires' => 48,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| User Model
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'UserModel' => App\Models\User::class,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Invitation Model
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'InvitationModel' => 'Junaidnasir\Larainvite\Models\LaraInviteModel',
|
||||
];
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable('invitations')) {
|
||||
// Table exists, let's modify it to add missing columns for our custom system
|
||||
Schema::table('invitations', function (Blueprint $table) {
|
||||
// Check and add columns that might be missing
|
||||
if (!Schema::hasColumn('invitations', 'token')) {
|
||||
$table->string('token', 64)->unique()->after('id');
|
||||
}
|
||||
|
||||
if (!Schema::hasColumn('invitations', 'email')) {
|
||||
$table->string('email')->after('token');
|
||||
}
|
||||
|
||||
if (!Schema::hasColumn('invitations', 'invited_by')) {
|
||||
$table->unsignedBigInteger('invited_by')->after('email');
|
||||
$table->index('invited_by');
|
||||
}
|
||||
|
||||
if (!Schema::hasColumn('invitations', 'expires_at')) {
|
||||
$table->timestamp('expires_at')->after('invited_by');
|
||||
}
|
||||
|
||||
if (!Schema::hasColumn('invitations', 'used_at')) {
|
||||
$table->timestamp('used_at')->nullable()->after('expires_at');
|
||||
}
|
||||
|
||||
if (!Schema::hasColumn('invitations', 'used_by')) {
|
||||
$table->unsignedBigInteger('used_by')->nullable()->after('used_at');
|
||||
$table->index('used_by');
|
||||
}
|
||||
|
||||
if (!Schema::hasColumn('invitations', 'is_active')) {
|
||||
$table->boolean('is_active')->default(true)->after('used_by');
|
||||
}
|
||||
|
||||
if (!Schema::hasColumn('invitations', 'metadata')) {
|
||||
$table->json('metadata')->nullable()->after('is_active');
|
||||
}
|
||||
|
||||
// Add timestamps if they don't exist
|
||||
if (!Schema::hasColumn('invitations', 'created_at')) {
|
||||
$table->timestamps();
|
||||
}
|
||||
});
|
||||
|
||||
// Add composite indexes safely
|
||||
$this->addIndexSafely('invitations', ['token', 'is_active'], 'invitations_token_is_active_index');
|
||||
$this->addIndexSafely('invitations', ['email', 'is_active'], 'invitations_email_is_active_index');
|
||||
$this->addIndexSafely('invitations', ['expires_at'], 'invitations_expires_at_index');
|
||||
|
||||
} else {
|
||||
// Table doesn't exist, create it with full structure
|
||||
Schema::create('invitations', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('token', 64)->unique();
|
||||
$table->string('email');
|
||||
$table->unsignedBigInteger('invited_by');
|
||||
$table->timestamp('expires_at');
|
||||
$table->timestamp('used_at')->nullable();
|
||||
$table->unsignedBigInteger('used_by')->nullable();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->json('metadata')->nullable(); // For storing additional data like role, permissions, etc.
|
||||
$table->timestamps();
|
||||
|
||||
// Add indexes
|
||||
$table->index('invited_by');
|
||||
$table->index('used_by');
|
||||
$table->index(['token', 'is_active']);
|
||||
$table->index(['email', 'is_active']);
|
||||
$table->index('expires_at');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// Only drop columns we added, don't drop the entire table
|
||||
// in case there's existing data from the old system
|
||||
if (Schema::hasTable('invitations')) {
|
||||
Schema::table('invitations', function (Blueprint $table) {
|
||||
// Drop indexes first
|
||||
$this->dropIndexSafely('invitations', 'invitations_token_is_active_index');
|
||||
$this->dropIndexSafely('invitations', 'invitations_email_is_active_index');
|
||||
$this->dropIndexSafely('invitations', 'invitations_expires_at_index');
|
||||
|
||||
// Drop individual indexes
|
||||
$this->dropIndexSafely('invitations', 'invitations_invited_by_index');
|
||||
$this->dropIndexSafely('invitations', 'invitations_used_by_index');
|
||||
|
||||
// Drop columns we might have added
|
||||
$columnsToCheck = ['token', 'email', 'invited_by', 'expires_at', 'used_at', 'used_by', 'is_active', 'metadata'];
|
||||
foreach ($columnsToCheck as $column) {
|
||||
if (Schema::hasColumn('invitations', $column)) {
|
||||
$table->dropColumn($column);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely add an index if it doesn't exist
|
||||
*/
|
||||
private function addIndexSafely(string $table, array $columns, string $indexName): void
|
||||
{
|
||||
try {
|
||||
$indexes = DB::select("SHOW INDEX FROM `{$table}` WHERE Key_name = ?", [$indexName]);
|
||||
if (empty($indexes)) {
|
||||
Schema::table($table, function (Blueprint $table) use ($columns, $indexName) {
|
||||
$table->index($columns, $indexName);
|
||||
});
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Index creation failed, but continue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely drop an index if it exists
|
||||
*/
|
||||
private function dropIndexSafely(string $table, string $indexName): void
|
||||
{
|
||||
try {
|
||||
$indexes = DB::select("SHOW INDEX FROM `{$table}` WHERE Key_name = ?", [$indexName]);
|
||||
if (!empty($indexes)) {
|
||||
Schema::table($table, function (Blueprint $table) use ($indexName) {
|
||||
$table->dropIndex($indexName);
|
||||
});
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Index drop failed, but continue
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('invitations', function (Blueprint $table) {
|
||||
// First, check if we have the old column structure
|
||||
if (Schema::hasColumn('invitations', 'users_id') && !Schema::hasColumn('invitations', 'invited_by')) {
|
||||
// Drop the existing foreign key constraint first
|
||||
try {
|
||||
$table->dropForeign('FK_users_inv');
|
||||
} catch (\Exception $e) {
|
||||
// Foreign key might not exist or have different name
|
||||
}
|
||||
|
||||
// Rename the column from users_id to invited_by
|
||||
$table->renameColumn('users_id', 'invited_by');
|
||||
}
|
||||
|
||||
// Add missing columns that our system needs
|
||||
if (!Schema::hasColumn('invitations', 'token')) {
|
||||
$table->string('token', 64)->unique()->after('id');
|
||||
}
|
||||
|
||||
if (!Schema::hasColumn('invitations', 'email')) {
|
||||
$table->string('email')->after('token');
|
||||
}
|
||||
|
||||
if (!Schema::hasColumn('invitations', 'expires_at')) {
|
||||
$table->timestamp('expires_at')->after('invited_by');
|
||||
}
|
||||
|
||||
if (!Schema::hasColumn('invitations', 'used_at')) {
|
||||
$table->timestamp('used_at')->nullable()->after('expires_at');
|
||||
}
|
||||
|
||||
if (!Schema::hasColumn('invitations', 'used_by')) {
|
||||
$table->unsignedBigInteger('used_by')->nullable()->after('used_at');
|
||||
$table->index('used_by');
|
||||
}
|
||||
|
||||
if (!Schema::hasColumn('invitations', 'is_active')) {
|
||||
$table->boolean('is_active')->default(true)->after('used_by');
|
||||
}
|
||||
|
||||
if (!Schema::hasColumn('invitations', 'metadata')) {
|
||||
$table->json('metadata')->nullable()->after('is_active');
|
||||
}
|
||||
|
||||
// Add timestamps if they don't exist
|
||||
if (!Schema::hasColumn('invitations', 'created_at')) {
|
||||
$table->timestamps();
|
||||
}
|
||||
});
|
||||
|
||||
// Add indexes safely
|
||||
$this->addIndexSafely('invitations', ['invited_by'], 'invitations_invited_by_index');
|
||||
$this->addIndexSafely('invitations', ['token', 'is_active'], 'invitations_token_is_active_index');
|
||||
$this->addIndexSafely('invitations', ['email', 'is_active'], 'invitations_email_is_active_index');
|
||||
$this->addIndexSafely('invitations', ['expires_at'], 'invitations_expires_at_index');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('invitations', function (Blueprint $table) {
|
||||
// Drop indexes first
|
||||
$this->dropIndexSafely('invitations', 'invitations_invited_by_index');
|
||||
$this->dropIndexSafely('invitations', 'invitations_token_is_active_index');
|
||||
$this->dropIndexSafely('invitations', 'invitations_email_is_active_index');
|
||||
$this->dropIndexSafely('invitations', 'invitations_expires_at_index');
|
||||
$this->dropIndexSafely('invitations', 'invitations_used_by_index');
|
||||
|
||||
// Rename back to original column name if it was renamed
|
||||
if (Schema::hasColumn('invitations', 'invited_by') && !Schema::hasColumn('invitations', 'users_id')) {
|
||||
$table->renameColumn('invited_by', 'users_id');
|
||||
}
|
||||
|
||||
// Drop columns we might have added
|
||||
$columnsToCheck = ['token', 'email', 'expires_at', 'used_at', 'used_by', 'is_active', 'metadata'];
|
||||
foreach ($columnsToCheck as $column) {
|
||||
if (Schema::hasColumn('invitations', $column)) {
|
||||
$table->dropColumn($column);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely add an index if it doesn't exist
|
||||
*/
|
||||
private function addIndexSafely(string $table, array $columns, string $indexName): void
|
||||
{
|
||||
try {
|
||||
$indexes = DB::select("SHOW INDEX FROM `{$table}` WHERE Key_name = ?", [$indexName]);
|
||||
if (empty($indexes)) {
|
||||
Schema::table($table, function (Blueprint $table) use ($columns, $indexName) {
|
||||
$table->index($columns, $indexName);
|
||||
});
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Index creation failed, but continue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely drop an index if it exists
|
||||
*/
|
||||
private function dropIndexSafely(string $table, string $indexName): void
|
||||
{
|
||||
try {
|
||||
$indexes = DB::select("SHOW INDEX FROM `{$table}` WHERE Key_name = ?", [$indexName]);
|
||||
if (!empty($indexes)) {
|
||||
Schema::table($table, function (Blueprint $table) use ($indexName) {
|
||||
$table->dropIndex($indexName);
|
||||
});
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Index drop failed, but continue
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -22,15 +22,15 @@ RUN apt-get update \
|
||||
&& curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg >/dev/null \
|
||||
&& echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt noble-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y php8.3 php8.3-cli php8.3-common php8.3-mysql php8.3-xdebug php8.3-dev \
|
||||
php8.3-amqp php8.3-bcmath php8.3-bz2 php8.3-curl php8.3-gd php8.3-gmp php8.3-gnupg \
|
||||
php8.3-http php8.3-igbinary php8.3-imagick php8.3-intl php8.3-mbstring php8.3-msgpack \
|
||||
php8.3-oauth php8.3-opcache php8.3-raphf php8.3-readline php8.3-redis php8.3-soap php8.3-sqlite3 \
|
||||
php8.3-swoole php8.3-tidy php8.3-uploadprogress php8.3-uuid php8.3-xml php8.3-xmlrpc php8.3-xsl \
|
||||
php8.3-yaml php8.3-zip php8.3-zstd php8.3-pgsql php8.3-sockets php8.3-imap php8.3-ldap php8.3-memcached php8.3-pcov \
|
||||
&& apt-get install -y php8.4 php8.4-cli php8.4-common php8.4-mysql php8.4-xdebug php8.4-dev \
|
||||
php8.4-amqp php8.4-bcmath php8.4-bz2 php8.4-curl php8.4-gd php8.4-gmp php8.4-gnupg \
|
||||
php8.4-http php8.4-igbinary php8.4-imagick php8.4-intl php8.4-mbstring php8.4-msgpack \
|
||||
php8.4-oauth php8.4-opcache php8.4-raphf php8.4-readline php8.4-redis php8.4-soap php8.4-sqlite3 \
|
||||
php8.4-swoole php8.4-tidy php8.4-uploadprogress php8.4-uuid php8.4-xml php8.4-xmlrpc php8.4-xsl \
|
||||
php8.4-yaml php8.4-zip php8.4-zstd php8.4-pgsql php8.4-sockets php8.4-imap php8.4-ldap php8.4-memcached php8.4-pcov \
|
||||
tmux iputils-ping net-tools ffmpeg \
|
||||
sudo jq htop fonts-powerline nano bash-completion time wget cron \
|
||||
nginx php8.3-fpm \
|
||||
nginx php8.4-fpm \
|
||||
jpegoptim webp optipng pngquant libavif-bin \
|
||||
&& wget https://mediaarea.net/repo/deb/repo-mediaarea_1.0-25_all.deb \
|
||||
&& dpkg -i repo-mediaarea_1.0-25_all.deb \
|
||||
@@ -69,7 +69,7 @@ RUN chmod 0644 /etc/cron.d/scheduler \
|
||||
COPY start-container /usr/local/bin/start-container
|
||||
COPY nginx.conf /etc/nginx/sites-available/default
|
||||
COPY supervisord.conf /etc/supervisor/supervisord.conf
|
||||
COPY php.ini /etc/php/8.3/cli/conf.d/99-sail.ini
|
||||
COPY php.ini /etc/php/8.4/cli/conf.d/99-sail.ini
|
||||
|
||||
RUN chmod +x /usr/local/bin/start-container
|
||||
|
||||
@@ -14,7 +14,7 @@ nginx runs on port 80 and is mapped to port 80 on the host machine too. If you'r
|
||||
|
||||
Mailpit is used as a fake mailserver which catches all e-mails in one inbox. You can acess this via [this link](http://localhost:8025). Mailpit's SMTP server runs on port `1025`.
|
||||
|
||||
> This project currently uses PHP 8.3 and MariaDB 11.x
|
||||
> This project currently uses PHP 8.4 and MariaDB 11.x
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -38,7 +38,7 @@ server {
|
||||
|
||||
location ~ \.php$ {
|
||||
include /etc/nginx/fastcgi_params;
|
||||
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
|
||||
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
|
||||
fastcgi_index index.php;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
}
|
||||
@@ -46,4 +46,4 @@ server {
|
||||
location ~ /\.ht {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ serverurl=unix:////var/run/supervisor.sock
|
||||
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
||||
|
||||
[program:php-fpm]
|
||||
command=/usr/sbin/php-fpm8.3 --nodaemonize -c /etc/php/8.3/fpm/php-fpm.conf
|
||||
command=/usr/sbin/php-fpm8.4 --nodaemonize -c /etc/php/8.4/fpm/php-fpm.conf
|
||||
autostart=true
|
||||
autorestart=true
|
||||
redirect_stderr=true
|
||||
@@ -0,0 +1,99 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Invitation to {{ $siteName }}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.header {
|
||||
background-color: #f8f9fa;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.content {
|
||||
background-color: #ffffff;
|
||||
padding: 20px;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
background-color: #007bff;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 4px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 20px;
|
||||
padding: 20px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
}
|
||||
.expiry-notice {
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
color: #856404;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>You're Invited!</h1>
|
||||
<p>{{ $invitedBy->username }} has invited you to join {{ $siteName }}</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<h2>Welcome to {{ $siteName }}</h2>
|
||||
|
||||
<p>Hello!</p>
|
||||
|
||||
<p>You have been invited by <strong>{{ $invitedBy->username }}</strong> to join {{ $siteName }}, our exclusive community.</p>
|
||||
|
||||
<p>To accept this invitation and create your account, click the button below:</p>
|
||||
|
||||
<div style="text-align: center;">
|
||||
<a href="{{ $registerUrl }}" class="button">Accept Invitation</a>
|
||||
</div>
|
||||
|
||||
<p>Or copy and paste this link into your browser:</p>
|
||||
<p style="word-break: break-all; background-color: #f8f9fa; padding: 10px; border-radius: 4px;">
|
||||
{{ $registerUrl }}
|
||||
</p>
|
||||
|
||||
<div class="expiry-notice">
|
||||
<strong>⚠️ Important:</strong> This invitation will expire on {{ $expiresAt->format('F j, Y \a\t g:i A') }}.
|
||||
Please complete your registration before this date.
|
||||
</div>
|
||||
|
||||
<p>If you have any questions, please don't hesitate to contact us.</p>
|
||||
|
||||
<p>Welcome aboard!</p>
|
||||
<p>The {{ $siteName }} Team</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>This invitation was sent to {{ $invitation->email }}. If you did not expect this invitation, you can safely ignore this email.</p>
|
||||
<p>© {{ date('Y') }} {{ $siteName }}. All rights reserved.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -247,6 +247,12 @@
|
||||
<i class="fa fa-television fa-fw me-2"></i>My Shows
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{route('invitations.index')}}">
|
||||
<i class="fa fa-envelope fa-fw me-2"></i>My Invitations
|
||||
</a>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{route('profileedit')}}">
|
||||
<i class="fa fa-cog fa-fw me-2"></i>Account Settings
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
<div class="header">
|
||||
<div class="breadcrumb-wrapper">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{$site->home_link}">Home</a></li>
|
||||
<li class="breadcrumb-item"><a href="{url('/profile')}">Profile</a></li>
|
||||
<li class="breadcrumb-item"><a href="{url('/invitations')}">My Invitations</a></li>
|
||||
<li class="breadcrumb-item active">Send New Invitation</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container-fluid px-4 py-3">
|
||||
<div class="row">
|
||||
<div class="col-md-8 mx-auto">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="fa fa-paper-plane me-2"></i>Send New Invitation</h5>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="badge {if $user_invites_left > 0}bg-success{else}bg-danger{/if} text-white">
|
||||
<i class="fa fa-envelope me-1"></i>
|
||||
{$user_invites_left} invites left
|
||||
</div>
|
||||
<a href="{url('/invitations')}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i> Back to Invitations
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{if $smarty.session.error}
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-exclamation-triangle me-2"></i>{$smarty.session.error}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{if $errors}
|
||||
<div class="alert alert-danger">
|
||||
<ul class="mb-0">
|
||||
{foreach $errors as $error}
|
||||
<li>{$error}</li>
|
||||
{/foreach}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{if !$can_send_invites}
|
||||
<div class="alert alert-warning text-center">
|
||||
<i class="fa fa-exclamation-triangle fa-3x mb-3"></i>
|
||||
<h5>No Invitations Available</h5>
|
||||
<p class="mb-3">You have used all of your available invitations. You cannot send new invitations at this time.</p>
|
||||
<div class="d-flex gap-2 justify-content-center">
|
||||
<a href="{url('/invitations')}" class="btn btn-primary">
|
||||
<i class="fa fa-arrow-left me-1"></i> Back to My Invitations
|
||||
</a>
|
||||
<a href="{url('/contact')}" class="btn btn-outline-primary">
|
||||
<i class="fa fa-envelope me-1"></i> Contact Support
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{else}
|
||||
<form method="POST" action="{url('/invitations/store')}">
|
||||
<input type="hidden" name="_token" value="{$csrf_token}">
|
||||
|
||||
<div class="alert alert-info mb-4">
|
||||
<div class="d-flex">
|
||||
<i class="fa fa-info-circle fa-lg me-3 mt-1"></i>
|
||||
<div>
|
||||
<strong>Available Invitations:</strong> You have <strong>{$user_invites_left}</strong> invitation{if $user_invites_left != 1}s{/if} remaining.
|
||||
{if $user_invites_left == 1}
|
||||
This is your last invitation, so use it wisely!
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="email" class="form-label">
|
||||
<i class="fa fa-envelope me-1"></i>Email Address <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="email"
|
||||
class="form-control {if $errors.email}is-invalid{/if}"
|
||||
id="email"
|
||||
name="email"
|
||||
value="{$old.email|default:''|escape:'htmlall'}"
|
||||
required
|
||||
placeholder="Enter recipient's email address">
|
||||
{if $errors.email}
|
||||
<div class="invalid-feedback">{$errors.email}</div>
|
||||
{/if}
|
||||
<div class="form-text">
|
||||
<i class="fa fa-info-circle me-1"></i>The person will receive an invitation email at this address.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="expiry_days" class="form-label">
|
||||
<i class="fa fa-clock-o me-1"></i>Expiry Period
|
||||
</label>
|
||||
<select class="form-select {if $errors.expiry_days}is-invalid{/if}"
|
||||
id="expiry_days"
|
||||
name="expiry_days">
|
||||
<option value="1" {if $old.expiry_days == '1'}selected{/if}>1 Day</option>
|
||||
<option value="3" {if $old.expiry_days == '3'}selected{/if}>3 Days</option>
|
||||
<option value="7" {if $old.expiry_days == '7' || !$old.expiry_days}selected{/if}>1 Week (Default)</option>
|
||||
<option value="14" {if $old.expiry_days == '14'}selected{/if}>2 Weeks</option>
|
||||
<option value="30" {if $old.expiry_days == '30'}selected{/if}>1 Month</option>
|
||||
</select>
|
||||
{if $errors.expiry_days}
|
||||
<div class="invalid-feedback">{$errors.expiry_days}</div>
|
||||
{/if}
|
||||
<div class="form-text">
|
||||
<i class="fa fa-info-circle me-1"></i>How long the invitation will remain valid.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{if $user_roles}
|
||||
<div class="mb-4">
|
||||
<label for="role" class="form-label">
|
||||
<i class="fa fa-user-tag me-1"></i>Default Role <small class="text-muted">(Optional)</small>
|
||||
</label>
|
||||
<select class="form-select {if $errors.role}is-invalid{/if}"
|
||||
id="role"
|
||||
name="role">
|
||||
<option value="">Use System Default</option>
|
||||
{foreach $user_roles as $roleId => $roleName}
|
||||
<option value="{$roleId}" {if $old.role == $roleId}selected{/if}>
|
||||
{$roleName|escape:'htmlall'}
|
||||
</option>
|
||||
{/foreach}
|
||||
</select>
|
||||
{if $errors.role}
|
||||
<div class="invalid-feedback">{$errors.role}</div>
|
||||
{/if}
|
||||
<div class="form-text">
|
||||
<i class="fa fa-info-circle me-1"></i>The role to assign to the user when they accept the invitation.
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="alert alert-info mb-4">
|
||||
<div class="d-flex">
|
||||
<i class="fa fa-lightbulb-o fa-lg me-3 mt-1"></i>
|
||||
<div>
|
||||
<strong>How it works:</strong>
|
||||
<ul class="mb-0 mt-2">
|
||||
<li>The recipient will receive an email with a secure invitation link</li>
|
||||
<li>They can use this link to create their account within the specified time period</li>
|
||||
<li>Once the time expires, the invitation link becomes invalid</li>
|
||||
<li>You can track the status of all your invitations from the main invitations page</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||
<a href="{url('/invitations')}" class="btn btn-outline-secondary me-md-2">
|
||||
<i class="fa fa-times me-1"></i>Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-paper-plane me-1"></i> Send Invitation
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,222 @@
|
||||
<div class="header">
|
||||
<div class="breadcrumb-wrapper">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{$site->home_link}">Home</a></li>
|
||||
<li class="breadcrumb-item"><a href="{url('/profile')}">Profile</a></li>
|
||||
<li class="breadcrumb-item active">My Invitations</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container-fluid px-4 py-3">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="fa fa-envelope me-2"></i>My Invitations</h5>
|
||||
<a href="{url('/invitations/create')}" class="btn btn-primary">
|
||||
<i class="fas fa-plus me-1"></i> Send New Invitation
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{if $smarty.session.success}
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
{$smarty.session.success}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{if $smarty.session.error}
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
{$smarty.session.error}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card bg-primary text-white">
|
||||
<div class="card-body text-center">
|
||||
<h4 class="mb-1">{$stats.total|default:0}</h4>
|
||||
<small>Total Sent</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card bg-success text-white">
|
||||
<div class="card-body text-center">
|
||||
<h4 class="mb-1">{$stats.used|default:0}</h4>
|
||||
<small>Accepted</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card bg-warning text-white">
|
||||
<div class="card-body text-center">
|
||||
<h4 class="mb-1">{$stats.pending|default:0}</h4>
|
||||
<small>Pending</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card bg-danger text-white">
|
||||
<div class="card-body text-center">
|
||||
<h4 class="mb-1">{$stats.expired|default:0}</h4>
|
||||
<small>Expired</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Tabs -->
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {if !$status}active{/if}" href="{url('/invitations')}">
|
||||
<i class="fa fa-list me-1"></i>All
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {if $status == 'pending'}active{/if}" href="{url('/invitations?status=pending')}">
|
||||
<i class="fa fa-clock-o me-1"></i>Pending
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {if $status == 'used'}active{/if}" href="{url('/invitations?status=used')}">
|
||||
<i class="fa fa-check me-1"></i>Accepted
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {if $status == 'expired'}active{/if}" href="{url('/invitations?status=expired')}">
|
||||
<i class="fa fa-times me-1"></i>Expired
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Invitations Table -->
|
||||
{if $invitations|@count > 0}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th><i class="fa fa-envelope me-1"></i>Email</th>
|
||||
<th><i class="fa fa-info-circle me-1"></i>Status</th>
|
||||
<th><i class="fa fa-calendar me-1"></i>Sent Date</th>
|
||||
<th><i class="fa fa-clock-o me-1"></i>Expires</th>
|
||||
<th><i class="fa fa-user me-1"></i>Used By</th>
|
||||
<th><i class="fa fa-check me-1"></i>Used Date</th>
|
||||
<th><i class="fa fa-cogs me-1"></i>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{foreach $invitations as $invitation}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fa fa-envelope text-muted me-2"></i>
|
||||
{$invitation.email|escape:"htmlall"}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{if $invitation.used_at}
|
||||
<span class="badge bg-success"><i class="fa fa-check me-1"></i>Accepted</span>
|
||||
{elseif $invitation.expires_at < $smarty.now}
|
||||
<span class="badge bg-danger"><i class="fa fa-times me-1"></i>Expired</span>
|
||||
{elseif !$invitation.is_active}
|
||||
<span class="badge bg-secondary"><i class="fa fa-ban me-1"></i>Cancelled</span>
|
||||
{else}
|
||||
<span class="badge bg-warning"><i class="fa fa-clock-o me-1"></i>Pending</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fa fa-calendar text-muted me-2"></i>
|
||||
{$invitation.created_at|date_format:'M j, Y'}
|
||||
<small class="text-muted ms-1">{$invitation.created_at|date_format:'H:i'}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fa fa-clock-o text-muted me-2"></i>
|
||||
{$invitation.expires_at|date_format:'M j, Y'}
|
||||
<small class="text-muted ms-1">{$invitation.expires_at|date_format:'H:i'}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{if $invitation.used_by_user}
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fa fa-user text-muted me-2"></i>
|
||||
{$invitation.used_by_user.username|default:$invitation.used_by_user.email|escape:"htmlall"}
|
||||
</div>
|
||||
{else}
|
||||
<span class="text-muted">-</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{if $invitation.used_at}
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fa fa-check text-muted me-2"></i>
|
||||
{$invitation.used_at|date_format:'M j, Y'}
|
||||
<small class="text-muted ms-1">{$invitation.used_at|date_format:'H:i'}</small>
|
||||
</div>
|
||||
{else}
|
||||
<span class="text-muted">-</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{if !$invitation.used_at && $invitation.expires_at > $smarty.now && $invitation.is_active}
|
||||
<div class="btn-group" role="group">
|
||||
<form method="POST" action="{url("/invitations/{$invitation.id}/resend")}" class="d-inline">
|
||||
<input type="hidden" name="_token" value="{$csrf_token}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary" title="Resend Invitation">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="{url("/invitations/{$invitation.id}")}" class="d-inline">
|
||||
<input type="hidden" name="_token" value="{$csrf_token}">
|
||||
<input type="hidden" name="_method" value="DELETE">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Cancel Invitation"
|
||||
onclick="return confirm('Are you sure you want to cancel this invitation?')">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{else}
|
||||
<span class="text-muted">-</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/foreach}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{if $pagination_links}
|
||||
<div class="d-flex justify-content-center">
|
||||
{$pagination_links}
|
||||
</div>
|
||||
{/if}
|
||||
{else}
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-envelope fa-4x text-muted mb-3"></i>
|
||||
<h5 class="text-muted">No invitations found</h5>
|
||||
<p class="text-muted mb-4">
|
||||
{if $status}
|
||||
No {$status} invitations to display.
|
||||
{else}
|
||||
You haven't sent any invitations yet.
|
||||
{/if}
|
||||
</p>
|
||||
<a href="{url('/invitations/create')}" class="btn btn-primary">
|
||||
<i class="fa fa-plus me-1"></i>Send Your First Invitation
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,143 @@
|
||||
<div class="header">
|
||||
<div class="breadcrumb-wrapper">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{$site->home_link}">Home</a></li>
|
||||
<li class="breadcrumb-item active">Invitation</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container-fluid px-4 py-3">
|
||||
<div class="row">
|
||||
<div class="col-md-8 mx-auto">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="fa fa-envelope-open me-2"></i>Invitation to Join {$site->title}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{if $preview}
|
||||
<div class="alert alert-info border-0 mb-4">
|
||||
<div class="d-flex">
|
||||
<i class="fa fa-user-plus fa-2x me-3 mt-1"></i>
|
||||
<div>
|
||||
<h6 class="alert-heading mb-2">You've been invited!</h6>
|
||||
<p class="mb-0">
|
||||
<strong>{$preview.inviter_name|default:'Someone'|escape:'htmlall'}</strong> has invited you to join <strong>{$site->title}</strong>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card border-0 bg-light">
|
||||
<div class="card-header bg-transparent border-bottom">
|
||||
<h6 class="mb-0"><i class="fa fa-info-circle me-1"></i>Invitation Details</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-5 text-muted"><i class="fa fa-user me-1"></i>Invited by:</div>
|
||||
<div class="col-7 fw-medium">{$preview.inviter_name|default:'Anonymous'|escape:'htmlall'}</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-5 text-muted"><i class="fa fa-envelope me-1"></i>Email:</div>
|
||||
<div class="col-7 fw-medium">{$preview.email|default:'N/A'|escape:'htmlall'}</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-5 text-muted"><i class="fa fa-clock-o me-1"></i>Expires:</div>
|
||||
<div class="col-7">
|
||||
{if $preview.expires_at}
|
||||
{$preview.expires_at|date_format:'M j, Y H:i'}
|
||||
{if $preview.expires_at < $smarty.now}
|
||||
<br><span class="badge bg-danger mt-1"><i class="fa fa-times me-1"></i>Expired</span>
|
||||
{elseif $preview.is_used}
|
||||
<br><span class="badge bg-success mt-1"><i class="fa fa-check me-1"></i>Already Used</span>
|
||||
{else}
|
||||
<br><span class="badge bg-success mt-1"><i class="fa fa-check me-1"></i>Valid</span>
|
||||
{/if}
|
||||
{else}
|
||||
N/A
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{if $preview.metadata.role}
|
||||
<div class="row">
|
||||
<div class="col-5 text-muted"><i class="fa fa-user-tag me-1"></i>Assigned Role:</div>
|
||||
<div class="col-7 fw-medium">
|
||||
<span class="badge bg-primary">{$preview.role_name|default:'Default'|escape:'htmlall'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card border-0 bg-light">
|
||||
<div class="card-header bg-transparent border-bottom">
|
||||
<h6 class="mb-0"><i class="fa fa-list-ol me-1"></i>What's Next?</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-3">To accept this invitation and create your account:</p>
|
||||
<ol class="mb-0">
|
||||
<li class="mb-2">Click the "Accept Invitation" button below</li>
|
||||
<li class="mb-2">Fill out the registration form with your details</li>
|
||||
<li class="mb-2">Verify your email address when prompted</li>
|
||||
<li class="mb-0">Start exploring {$site->title}!</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
{if $preview.is_used}
|
||||
<div class="alert alert-success border-0 mb-4">
|
||||
<i class="fas fa-check-circle fa-2x mb-2"></i>
|
||||
<h6>This invitation has already been used</h6>
|
||||
<p class="mb-0">The account has been successfully created using this invitation.</p>
|
||||
</div>
|
||||
<a href="{url('/login')}" class="btn btn-primary btn-lg">
|
||||
<i class="fas fa-sign-in-alt me-2"></i> Login to Your Account
|
||||
</a>
|
||||
{elseif $preview.expires_at < $smarty.now}
|
||||
<div class="alert alert-danger border-0 mb-4">
|
||||
<i class="fas fa-exclamation-triangle fa-2x mb-2"></i>
|
||||
<h6>This invitation has expired</h6>
|
||||
<p class="mb-0">Please contact the person who invited you for a new invitation.</p>
|
||||
</div>
|
||||
<a href="{url('/contact')}" class="btn btn-outline-primary">
|
||||
<i class="fa fa-envelope me-1"></i> Contact Support
|
||||
</a>
|
||||
{else}
|
||||
<a href="{url("/register?invitation={$token}")}" class="btn btn-primary btn-lg shadow">
|
||||
<i class="fas fa-user-plus me-2"></i> Accept Invitation & Create Account
|
||||
</a>
|
||||
<div class="mt-3">
|
||||
<small class="text-muted">
|
||||
Already have an account? <a href="{url('/login')}" class="text-decoration-none">Login here</a>
|
||||
</small>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{else}
|
||||
<div class="alert alert-danger border-0 text-center">
|
||||
<i class="fas fa-exclamation-triangle fa-3x mb-3 text-danger"></i>
|
||||
<h5 class="text-danger">Invalid Invitation</h5>
|
||||
<p class="mb-4">This invitation link is not valid, has expired, or has been removed.</p>
|
||||
<div class="d-flex gap-2 justify-content-center">
|
||||
<a href="{url('/contact')}" class="btn btn-outline-primary">
|
||||
<i class="fa fa-envelope me-1"></i> Contact Support
|
||||
</a>
|
||||
<a href="{url('/register')}" class="btn btn-primary">
|
||||
<i class="fa fa-user-plus me-1"></i> Register Without Invitation
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -50,8 +50,8 @@
|
||||
<i class="fa fa-key me-2"></i>API & Downloads
|
||||
</a>
|
||||
{if ($user.id === $userdata.id || $isadmin === "true") && $site->registerstatus == 1}
|
||||
<a href="#invites" class="list-group-item list-group-item-action">
|
||||
<i class="fa fa-envelope me-2"></i>Invites
|
||||
<a href="{{route('invitations.index')}}" class="list-group-item list-group-item-action">
|
||||
<i class="fa fa-envelope me-2"></i>My Invitations
|
||||
</a>
|
||||
{/if}
|
||||
{if (isset($isadmin) && $isadmin === "true") && $downloadlist|@count > 0}
|
||||
|
||||
@@ -70,6 +70,7 @@ use App\Http\Controllers\RssController;
|
||||
use App\Http\Controllers\SearchController;
|
||||
use App\Http\Controllers\SeriesController;
|
||||
use App\Http\Controllers\TermsController;
|
||||
use App\Http\Controllers\InvitationController;
|
||||
|
||||
// Auth::routes();
|
||||
|
||||
@@ -225,4 +226,20 @@ Route::middleware('role_or_permission:Admin|Moderator|edit release')->prefix('ad
|
||||
Route::match(['GET', 'POST'], 'release-edit', [AdminReleasesController::class, 'edit'])->name('admin.release-edit');
|
||||
});
|
||||
|
||||
// Invitation management routes
|
||||
Route::prefix('invitations')->name('invitations.')->group(function () {
|
||||
Route::get('/', [InvitationController::class, 'index'])->name('index');
|
||||
Route::get('/create', [InvitationController::class, 'create'])->name('create');
|
||||
Route::post('/store', [InvitationController::class, 'store'])->name('store');
|
||||
Route::post('/{id}/resend', [InvitationController::class, 'resend'])->name('resend');
|
||||
Route::delete('/{id}', [InvitationController::class, 'destroy'])->name('destroy');
|
||||
Route::get('/stats', [InvitationController::class, 'stats'])->name('stats');
|
||||
});
|
||||
|
||||
// Public invitation view (no auth required)
|
||||
Route::get('/invitation/{token}', [InvitationController::class, 'show'])->name('invitation.show');
|
||||
|
||||
// Admin invitation cleanup
|
||||
Route::post('/admin/invitations/cleanup', [InvitationController::class, 'cleanup'])->name('admin.invitations.cleanup')->middleware('role:Admin');
|
||||
|
||||
Route::post('btcpay/webhook', [BtcPaymentController::class, 'btcPayCallback'])->name('btcpay.webhook');
|
||||
|
||||
Reference in New Issue
Block a user