Add custom invitation system

This commit is contained in:
DariusIII
2025-08-10 23:47:11 +02:00
parent 4b35ab2a9c
commit 88343da88a
25 changed files with 1947 additions and 178 deletions
+106 -17
View File
@@ -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
]);
}
}
+62
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+259
View File
@@ -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
];
}
}
-1
View File
@@ -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
View File
@@ -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",
-29
View File
@@ -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>
+2 -2
View File
@@ -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}
+17
View File
@@ -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');