Add admin invite interface and email support

This commit does two things:

* Add invite email support to AdminInviteCommand
  - Moves `invite_code` generation to AdminInvite model `creating` event
* Add admin invite management section under admin users dashboard
  - Adds `Admin/AdminUserInviteController` and associated `home` and
    `create` Blade templates.
  - Adds "Invites" button to admin user dashboard
pull/6189/head
Ross Bearman 8 months ago
parent 12b388caa0
commit db03733415

@ -2,9 +2,11 @@
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Mail\AdminInviteEmail;
use App\Models\AdminInvite;
use Illuminate\Support\Str;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Validator;
class AdminInviteCommand extends Command
{
@ -34,43 +36,30 @@ class AdminInviteCommand extends Command
$this->info(' / /_/ / / |/_/ _ \/ / /_/ _ \/ __ / ');
$this->info(' / ____/ /> </ __/ / __/ __/ /_/ / ');
$this->info(' /_/ /_/_/|_|\___/_/_/ \___/\__,_/ ');
$this->info(' ');
$this->line(' ');
$this->info(' Pixelfed Admin Inviter');
$this->line(' ');
$this->info(' Manage user registration invite links');
$this->line(' ');
$action = $this->choice(
return match ($this->choice(
'Select an action',
[
'Create invite',
'View invites',
'Expire invite',
'Cancel'
'Cancel',
],
3
);
switch($action) {
case 'Create invite':
return $this->create();
break;
case 'View invites':
return $this->view();
break;
case 'Expire invite':
return $this->expire();
break;
case 'Cancel':
return;
break;
}
)) {
'Create invite' => $this->create(),
'View invites' => $this->view(),
'Expire invite' => $this->expire(),
default => Command::SUCCESS,
};
}
protected function create()
protected function create(): int
{
$this->info('Create Invite');
$this->line('=============');
@ -86,94 +75,121 @@ class AdminInviteCommand extends Command
$this->info('Set maximum # of invite uses, use 0 for unlimited');
$max_uses = $this->ask('Max uses', 1);
$shouldExpire = $this->choice(
$expires = match ($this->choice(
'Set an invite expiry date?',
[
'No - invite never expires',
'Yes - expire after 24 hours',
'Custom - let me pick an expiry date'
'Custom - let me pick an expiry date',
],
0
);
switch($shouldExpire) {
case 'No - invite never expires':
$expires = null;
break;
case 'Yes - expire after 24 hours':
$expires = now()->addHours(24);
break;
case 'Custom - let me pick an expiry date':
$this->info('Set custom expiry date in days');
$customExpiry = $this->ask('Custom Expiry', 14);
$expires = now()->addDays($customExpiry);
break;
}
$this->info('Skip email verification for invitees?');
$skipEmailVerification = $this->choice('Skip email verification', ['No', 'Yes'], 0);
$invite = new AdminInvite;
$invite->name = $name;
$invite->description = $description;
$invite->message = $message;
$invite->max_uses = $max_uses;
$invite->skip_email_verification = $skipEmailVerification === 'Yes';
$invite->expires_at = $expires;
$invite->invite_code = Str::uuid() . Str::random(random_int(1,6));
$invite->save();
$this->info('####################');
$this->info('# Invite Generated!');
)) {
'No - invite never expires' => null,
'Yes - expire after 24 hours' => now()->addHours(24),
'Custom - let me pick an expiry date' => now()->addDays(
(int) $this->ask('Custom expiry date in days', 14)
),
};
$skipEmailVerification = $this->confirm('Skip email verification for invitees?');
$invite = AdminInvite::create([
'name' => $name,
'description' => $description,
'message' => $message,
'max_uses' => $max_uses,
'skip_email_verification' => $skipEmailVerification,
'expires_at' => $expires,
]);
$this->info('#################');
$this->info('Invite Generated!');
$this->line(' ');
$this->info($invite->url());
$this->warn($invite->url());
$this->line(' ');
if ($this->confirm('Send invitation email to user?')) {
$email = $this->promptForEmail();
Mail::to($email)->queue(new AdminInviteEmail($invite));
$this->line(' ');
$this->info("Invite email sent to {$email}");
}
return Command::SUCCESS;
}
protected function view()
protected function view(): int
{
$this->info('View Invites');
$this->line('=============');
if(AdminInvite::count() == 0) {
$this->line('============');
if (AdminInvite::count() === 0) {
$this->line(' ');
$this->error('No invites found!');
return;
return Command::SUCCESS;
}
$this->table(
['Invite Code', 'Uses Left', 'Expires'],
AdminInvite::all(['invite_code', 'max_uses', 'uses', 'expires_at'])->map(function($invite) {
AdminInvite::all(['invite_code', 'max_uses', 'uses', 'expires_at'])->map(function ($invite) {
return [
'invite_code' => $invite->invite_code,
'uses_left' => $invite->max_uses ? ($invite->max_uses - $invite->uses) : '∞',
'expires_at' => $invite->expires_at ? $invite->expires_at->diffForHumans() : 'never'
'expires_at' => $invite->expires_at ? $invite->expires_at->diffForHumans() : 'never',
];
})->toArray()
);
return Command::SUCCESS;
}
protected function expire()
protected function expire(): int
{
$token = $this->anticipate('Enter invite code to expire', function($val) {
if(!$val || empty($val)) {
return [];
}
return AdminInvite::where('invite_code', 'like', '%' . $val . '%')->pluck('invite_code')->toArray();
$token = $this->anticipate('Enter invite code to expire', function ($val) {
return AdminInvite::query()
->where('invite_code', 'like', "%$val%")
->pluck('invite_code')
->toArray();
});
if(!$token || empty($token)) {
$this->error('Invalid invite code');
return;
}
$invite = AdminInvite::whereInviteCode($token)->first();
if(!$invite) {
if (! $invite) {
$this->error('Invalid invite code');
return;
return Command::FAILURE;
}
$invite->max_uses = 1;
$invite->expires_at = now()->subHours(2);
$invite->save();
$this->info('Expired the following invite: ' . $invite->url());
$this->info('Expired the following invite: '.$invite->url());
return Command::SUCCESS;
}
protected function promptForEmail(): string
{
do {
$email = $this->ask('What email should the invite be sent to?');
$validator = Validator::make(
['email' => $email],
['email' => ['required', 'email:rfc,dns']]
);
if ($validator->fails()) {
$this->error($validator->errors()->first('email'));
$this->line(' ');
continue;
}
return $email;
} while (true);
}
}

@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Mail\AdminInviteEmail;
use App\Models\AdminInvite;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
class AdminUserInviteController extends Controller
{
public function __construct()
{
$this->middleware('admin');
$this->middleware('dangerzone');
$this->middleware('twofactor');
}
public function index()
{
$invites = AdminInvite::orderByDesc('created_at')->simplePaginate(25);
return view('admin.users.invites.home', ['invites' => $invites]);
}
public function create()
{
return view('admin.users.invites.create');
}
public function store(Request $request)
{
$this->validate($request, [
'name' => 'nullable|string|max:255',
'description' => 'nullable|string|max:1000',
'message' => 'nullable|string|max:1000',
'email' => 'nullable|email:rfc,dns',
'max_uses' => 'required|integer|min:0',
'expires_in' => 'required|integer|min:0',
'skip_email_verification' => 'sometimes|boolean',
]);
$invite = AdminInvite::create([
'name' => $request->input('name') ?? 'Untitled Invite',
'description' => $request->input('description'),
'message' => $request->input('message'),
'max_uses' => $request->integer('max_uses'),
'skip_email_verification' => $request->boolean('skip_email_verification'),
'expires_at' => $request->integer('expires_in') > 0
? now()->addDays($request->integer('expires_in'))
: null,
'admin_user_id' => $request->user()->id,
]);
if ($request->input('email') !== null) {
Mail::to($request->input('email'))->queue(new AdminInviteEmail($invite));
}
return redirect(route('admin.users.invites.index'))
->with('status', 'Invite created <a href="'.$invite->url().'" class="text-white" style="text-decoration: underline;">'.$invite->url().'</a>.');
}
public function expire(AdminInvite $invite)
{
$invite->max_uses = 1;
$invite->expires_at = now()->subHours(2);
$invite->save();
return ['status' => 200, 'message' => 'Successfully expired invite!'];
}
}

@ -0,0 +1,33 @@
<?php
namespace App\Mail;
use App\Models\AdminInvite;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class AdminInviteEmail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public readonly AdminInvite $invite,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'You\'ve been invited to join '.config('app.name').'!',
);
}
public function content(): Content
{
return new Content(
markdown: 'emails.user.invite',
);
}
}

@ -2,20 +2,51 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class AdminInvite extends Model
{
use HasFactory;
protected $casts = [
'used_by' => 'array',
'expires_at' => 'datetime',
];
public function url()
protected $fillable = [
'name',
'description',
'message',
'max_uses',
'uses',
'skip_email_verification',
'expires_at',
'admin_user_id',
];
protected static function booted(): void
{
static::creating(function (AdminInvite $invite) {
$invite->invite_code = Str::uuid().Str::random(random_int(1, 6));
});
}
public function url(): string
{
return url('/auth/invite/a/'.$this->invite_code);
}
public function isActive(): bool
{
return $this->hasUsesRemaining() && ! $this->hasExpired();
}
public function hasExpired(): bool
{
return $this->expires_at?->isPast() ?? false;
}
public function hasUsesRemaining(): bool
{
return url('/auth/invite/a/' . $this->invite_code);
return $this->max_uses === 0 || is_null($this->max_uses) || $this->uses < $this->max_uses;
}
}

@ -10,6 +10,11 @@
<p class="text-muted mb-0">Manage and moderate user accounts</p>
</div>
<div class="col-md-6">
<div class="d-flex justify-content-md-end mb-4">
<a href="{{ route('admin.users.invites.index') }}" class="btn btn-secondary" title="Invites">
<span class="font-weight-bold">Invites</span>
</a>
</div>
<form method="get" class="d-flex justify-content-md-end">
<input type="hidden" name="a" value="search">
@if(request()->has('col'))<input type="hidden" name="col" value="{{request()->query('col')}}">@endif

@ -0,0 +1,113 @@
@extends('admin.partial.template-full')
@section('section')
<div class="title d-flex justify-content-between align-items-center">
<span><a href="{{ route('admin.users') }}" class="btn btn-outline-secondary btn-sm font-weight-bold">Back</a></span>
<span class="text-center">
<h3 class="font-weight-bold mb-0">Create Invite</h3>
</span>
<span>&nbsp;</span>
</div>
<hr>
<div class="col-12 col-md-8 offset-md-2">
<div class="row">
<div class="col-12">
<form method="post">
@csrf
<div class="form-group">
<label class="font-weight-bold text-muted">Invite Name (only visible to admins)</label>
<input
type="text"
class="form-control{{ $errors->has('name') ? ' is-invalid' : '' }}"
name="name"
placeholder="Untitled Invite"
value="{{ old('name') }}"
/>
@error('name')
<span class="invalid-feedback">{{ $message }}</span>
@enderror
</div>
<div class="form-group">
<label class="font-weight-bold text-muted">Invite Description (only visible to admins)</label>
<textarea
class="form-control{{ $errors->has('description') ? ' is-invalid' : '' }}"
rows="2"
name="description"
maxlength="1000">{{ old('description') }}</textarea>
@error('description')
<span class="invalid-feedback">{{ $message }}</span>
@enderror
</div>
<div class="form-group">
<label class="font-weight-bold text-muted">Message (shown to invitees)</label>
<textarea
class="form-control{{ $errors->has('message') ? ' is-invalid' : '' }}"
rows="2"
name="message"
maxlength="1000">{{ old('message', "You've been invited to join " . config('app.name')) }}</textarea>
@error('message')
<span class="invalid-feedback">{{ $message }}</span>
@enderror
</div>
<div class="form-group">
<label class="font-weight-bold text-muted">Email</label>
<input
type="email"
class="form-control{{ $errors->has('email') ? ' is-invalid' : '' }}"
name="email"
value="{{ old('email') }}"
/>
<p class="help-text small text-muted font-weight-bold">If provided, an invitation email will be sent to this address</p>
@error('email')
<span class="invalid-feedback">{{ $message }}</span>
@enderror
</div>
<div class="form-group">
<label class="font-weight-bold text-muted">Maximum number of uses</label>
<input
type="number"
min="0"
class="form-control{{ $errors->has('max_uses') ? ' is-invalid' : '' }}"
name="max_uses"
value="{{ old('max_uses', 1) }}"
required
/>
<p class="help-text small text-muted font-weight-bold">Use 0 for unlimited</p>
@error('max_uses')
<span class="invalid-feedback">{{ $message }}</span>
@enderror
</div>
<div class="form-group">
<label class="font-weight-bold text-muted">Expiry date in days</label>
<input
type="number"
min="0"
class="form-control{{ $errors->has('expires_in') ? ' is-invalid' : '' }}"
name="expires_in"
value="{{ old('expires_in', 0) }}"
required
/>
<p class="help-text small text-muted font-weight-bold">Use 0 for invite to never expire</p>
@error('expires_in')
<span class="invalid-feedback">{{ $message }}</span>
@enderror
</div>
<div class="form-group">
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input" id="skip_email_verification" name="skip_email_verification" value="1" @checked(old('skip_email_verification'))}}>
<label class="custom-control-label" for="skip_email_verification">Skip email verification</label>
</div>
@error('skip_email_verification')
<span class="invalid-feedback" style="display: block;">{{ $message }}</span>
@enderror
</div>
<hr>
<p class="float-right">
<button type="submit" class="btn btn-primary font-weight-bold py-1">CREATE</button>
</p>
</form>
</div>
</div>
</div>
@endsection

@ -0,0 +1,140 @@
@extends('admin.partial.template-full')
@section('section')
<div class="container-fluid">
<div class="row align-items-center mb-4">
<div class="col-md-6">
<div class="display-1 font-weight-bold text-dark mb-0">
Invites
</div>
<p class="text-muted mb-0">Manage admin-created user invitations</p>
</div>
<div class="col-md-6">
<div class="d-flex justify-content-md-end mb-4">
<a href="{{ route('admin.users.invites.create') }}" class="btn btn-primary" title="Invites">
<span class="font-weight-bold">New Invite</span>
</a>
</div>
</div>
</div>
@if (session('status'))
<div class="row justify-content-center">
<div class="col-12" id="flash">
<div class="alert alert-success">
{!! session('status') !!}
</div>
</div>
</div>
@endif
<div class="table-responsive">
<table class="table">
<thead class="bg-light">
<tr>
<th scope="col" class="border-0">
<span>Code</span>
</th>
<th scope="col" class="border-0">
<span>Name</span>
</th>
<th scope="col" class="border-0">
<span>Description</span>
</th>
<th scope="col" class="border-0">
<span>Remaining Uses</span>
</th>
<th scope="col" class="border-0">
<span>Expires</span>
</th>
<th scope="col" class="border-0">
<span>Actions</span>
</th>
</tr>
</thead>
<tbody>
@foreach($invites as $invite)
<tr class="font-weight-bold">
<td class="text-truncate" data-toggle="tooltip" data-placement="bottom" title="{{ $invite->description }}" style="max-width: 100px;">
<a href="{{ $invite->url() }}">{{ $invite->invite_code }}</a>
</td>
<td>
{{$invite->name}}
</td>
<td class="text-truncate" data-toggle="tooltip" data-placement="bottom" title="{{ $invite->description }}" style="max-width: 200px;">
{{ $invite->description }}
</td>
<td>
{{ $invite->max_uses ? ($invite->max_uses - $invite->uses) : '∞' }}
</td>
<td data-toggle="tooltip" data-placement="bottom" title="{{ $invite->expires_at?->toDateTimeString() ?? '' }}" style="max-width: 80px;">
{{ $invite->hasExpired() ? 'expired' : ($invite->expires_at?->diffForHumans() ?? 'never') }}
</td>
<td>
<button class="btn btn-outline-secondary btn-sm py-0 mr-3"
onclick="expireInvite('{{ $invite->name ?? $invite->invite_code }}', '{{ route('admin.users.invites.expire', $invite) }}')"
type="button">
Expire
</button>
</td>
</tr>
@endforeach
</tbody>
</table>
<div class="d-flex justify-content-center mt-5 small">
{{ $invites->links() }}
</div>
</div>
</div>
@endsection
@push('scripts')
<script>
function expireInvite (inviteName, deletionRoute) {
event.preventDefault()
swal({
title: 'Expire Invite',
text: `Are you sure you want to expire the invite "${inviteName}"? This action cannot be undone.`,
icon: 'warning',
dangerMode: true,
buttons: {
cancel: {
text: 'Cancel',
value: false,
visible: true,
},
expire: {
text: 'Expire',
value: 'expire',
className: 'btn-danger'
}
}
})
.then((willExpire) => {
if (willExpire === 'expire') {
swal({
title: 'Deleting...',
text: 'Please wait while we expire the invite.',
icon: 'info',
buttons: false,
closeOnClickOutside: false,
closeOnEsc: false
})
axios.post(deletionRoute)
.then(res => {
swal('Success!', 'Invite has been expired successfully.', 'success')
.then(() => {
window.location.reload()
})
})
.catch(err => {
console.error('Expire error:', err)
swal('Error!', 'Failed to expire invite. Please try again.', 'error')
})
}
})
}
</script>
@endpush

@ -0,0 +1,18 @@
<x-mail::message>
# You've been invited to join {{ config('app.name') }}!
<x-mail::panel>
{{ $invite->message }}
Click the link below to register your account.
</x-mail::panel>
<x-mail::button :url="$invite->url()">
Accept Invite
</x-mail::button>
Thanks,<br>
{{ config('app.name') }}
<small>This email is automatically generated. Please do not reply to this message.</small>
</x-mail::message>

@ -45,6 +45,10 @@ Route::domain(config('pixelfed.domain.admin'))->prefix('i/admin')->group(functio
Route::get('users/delete/{id}', 'AdminController@userDelete');
Route::post('users/delete/{id}', 'AdminController@userDeleteProcess');
Route::post('users/moderation/update', 'AdminController@userModerate');
Route::get('users/invites', 'Admin\AdminUserInviteController@index')->name('admin.users.invites.index');
Route::get('users/invites/create', 'Admin\AdminUserInviteController@create')->name('admin.users.invites.create');
Route::post('users/invites/create', 'Admin\AdminUserInviteController@store')->name('admin.users.invites.store');
Route::post('users/invites/expire/{invite:invite_code}', 'Admin\AdminUserInviteController@expire')->name('admin.users.invites.expire');
Route::get('media', 'AdminController@media')->name('admin.media');
Route::redirect('media/list', '/i/admin/media');
Route::get('media/show/{id}', 'AdminController@mediaShow');
@ -120,7 +124,7 @@ Route::domain(config('pixelfed.domain.admin'))->prefix('i/admin')->group(functio
Route::get('curated-onboarding/show/{id}/preview-message', 'AdminCuratedRegisterController@previewMessageShow');
Route::get('curated-onboarding/show/{id}', 'AdminCuratedRegisterController@show');
Route::prefix('api')->group(function() {
Route::prefix('api')->group(function () {
Route::get('stats', 'AdminController@getStats');
Route::get('accounts', 'AdminController@getAccounts');
Route::get('posts', 'AdminController@getPosts');

Loading…
Cancel
Save