mirror of https://github.com/pixelfed/pixelfed
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
parent
12b388caa0
commit
db03733415
@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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> </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>
|
||||
Loading…
Reference in New Issue