mirror of
https://github.com/pixelfed/pixelfed.git
synced 2024-11-22 06:21:27 +00:00
Add Admin Invites
This commit is contained in:
parent
434f1f229c
commit
b73ca9a1ea
10 changed files with 940 additions and 0 deletions
163
app/Console/Commands/AdminInviteCommand.php
Normal file
163
app/Console/Commands/AdminInviteCommand.php
Normal file
|
@ -0,0 +1,163 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use App\Models\AdminInvite;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class AdminInviteCommand extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'admin:invite';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Create an invite link';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
$this->info(' ____ _ ______ __ ');
|
||||||
|
$this->info(' / __ \(_) _____ / / __/__ ____/ / ');
|
||||||
|
$this->info(' / /_/ / / |/_/ _ \/ / /_/ _ \/ __ / ');
|
||||||
|
$this->info(' / ____/ /> </ __/ / __/ __/ /_/ / ');
|
||||||
|
$this->info(' /_/ /_/_/|_|\___/_/_/ \___/\__,_/ ');
|
||||||
|
$this->info(' ');
|
||||||
|
$this->info(' Pixelfed Admin Inviter');
|
||||||
|
$this->line(' ');
|
||||||
|
$this->info(' Manage user registration invite links');
|
||||||
|
$this->line(' ');
|
||||||
|
|
||||||
|
$action = $this->choice(
|
||||||
|
'Select an action',
|
||||||
|
[
|
||||||
|
'Create invite',
|
||||||
|
'View invites',
|
||||||
|
'Expire invite',
|
||||||
|
'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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function create()
|
||||||
|
{
|
||||||
|
$this->info('Create Invite');
|
||||||
|
$this->line('=============');
|
||||||
|
$this->info('Set an optional invite name (only visible to admins)');
|
||||||
|
$name = $this->ask('Invite Name (optional)', 'Untitled Invite');
|
||||||
|
|
||||||
|
$this->info('Set an optional invite description (only visible to admins)');
|
||||||
|
$description = $this->ask('Invite Description (optional)');
|
||||||
|
|
||||||
|
$this->info('Set an optional message to invitees (visible to all)');
|
||||||
|
$message = $this->ask('Invite Message (optional)', 'You\'ve been invited to join');
|
||||||
|
|
||||||
|
$this->info('Set maximum # of invite uses, use 0 for unlimited');
|
||||||
|
$max_uses = $this->ask('Max uses', 1);
|
||||||
|
|
||||||
|
$shouldExpire = $this->choice(
|
||||||
|
'Set an invite expiry date?',
|
||||||
|
[
|
||||||
|
'No - invite never expires',
|
||||||
|
'Yes - expire after 24 hours',
|
||||||
|
'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;
|
||||||
|
$invite->expires_at = $expires;
|
||||||
|
$invite->invite_code = Str::uuid() . Str::random(random_int(1,6));
|
||||||
|
$invite->save();
|
||||||
|
|
||||||
|
$this->info('####################');
|
||||||
|
$this->info('# Invite Generated!');
|
||||||
|
$this->line(' ');
|
||||||
|
$this->info($invite->url());
|
||||||
|
$this->line(' ');
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function view()
|
||||||
|
{
|
||||||
|
$this->info('View Invites');
|
||||||
|
$this->line('=============');
|
||||||
|
$this->table(
|
||||||
|
['Invite Code', 'Uses Left', 'Expires'],
|
||||||
|
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'
|
||||||
|
];
|
||||||
|
})->toArray()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function expire()
|
||||||
|
{
|
||||||
|
$token = $this->anticipate('Enter invite code to expire', function($val) {
|
||||||
|
return AdminInvite::where('invite_code', 'like', '%' . $val . '%')->pluck('invite_code')->toArray();
|
||||||
|
});
|
||||||
|
|
||||||
|
$invite = AdminInvite::whereInviteCode($token)->firstOrFail();
|
||||||
|
$invite->max_uses = 1;
|
||||||
|
$invite->expires_at = now()->subHours(2);
|
||||||
|
$invite->save();
|
||||||
|
$this->info('Expired the following invite: ' . $invite->url());
|
||||||
|
}
|
||||||
|
}
|
191
app/Http/Controllers/AdminInviteController.php
Normal file
191
app/Http/Controllers/AdminInviteController.php
Normal file
|
@ -0,0 +1,191 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use App\Models\AdminInvite;
|
||||||
|
use App\Profile;
|
||||||
|
use App\User;
|
||||||
|
use App\Util\Lexer\RestrictedNames;
|
||||||
|
use Illuminate\Foundation\Auth\RegistersUsers;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Auth\Events\Registered;
|
||||||
|
use App\Services\EmailService;
|
||||||
|
use App\Http\Controllers\Auth\RegisterController;
|
||||||
|
|
||||||
|
class AdminInviteController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
abort_if(!config('instance.admin_invites.enabled'), 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request, $code)
|
||||||
|
{
|
||||||
|
if($request->user()) {
|
||||||
|
return redirect('/');
|
||||||
|
}
|
||||||
|
return view('invite.admin_invite', compact('code'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function apiVerifyCheck(Request $request)
|
||||||
|
{
|
||||||
|
$this->validate($request, [
|
||||||
|
'token' => 'required',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$invite = AdminInvite::whereInviteCode($request->input('token'))->first();
|
||||||
|
abort_if(!$invite, 404);
|
||||||
|
abort_if($invite->expires_at && $invite->expires_at->lt(now()), 400, 'Invite has expired.');
|
||||||
|
abort_if($invite->max_uses && $invite->uses >= $invite->max_uses, 400, 'Maximum invites reached.');
|
||||||
|
$res = [
|
||||||
|
'message' => $invite->message,
|
||||||
|
'max_uses' => $invite->max_uses,
|
||||||
|
'sev' => $invite->skip_email_verification
|
||||||
|
];
|
||||||
|
return response()->json($res);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function apiUsernameCheck(Request $request)
|
||||||
|
{
|
||||||
|
$this->validate($request, [
|
||||||
|
'token' => 'required',
|
||||||
|
'username' => 'required'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$invite = AdminInvite::whereInviteCode($request->input('token'))->first();
|
||||||
|
abort_if(!$invite, 404);
|
||||||
|
abort_if($invite->expires_at && $invite->expires_at->lt(now()), 400, 'Invite has expired.');
|
||||||
|
abort_if($invite->max_uses && $invite->uses >= $invite->max_uses, 400, 'Maximum invites reached.');
|
||||||
|
|
||||||
|
$usernameRules = [
|
||||||
|
'required',
|
||||||
|
'min:2',
|
||||||
|
'max:15',
|
||||||
|
'unique:users',
|
||||||
|
function ($attribute, $value, $fail) {
|
||||||
|
$dash = substr_count($value, '-');
|
||||||
|
$underscore = substr_count($value, '_');
|
||||||
|
$period = substr_count($value, '.');
|
||||||
|
|
||||||
|
if(ends_with($value, ['.php', '.js', '.css'])) {
|
||||||
|
return $fail('Username is invalid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if(($dash + $underscore + $period) > 1) {
|
||||||
|
return $fail('Username is invalid. Can only contain one dash (-), period (.) or underscore (_).');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ctype_alnum($value[0])) {
|
||||||
|
return $fail('Username is invalid. Must start with a letter or number.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ctype_alnum($value[strlen($value) - 1])) {
|
||||||
|
return $fail('Username is invalid. Must end with a letter or number.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$val = str_replace(['_', '.', '-'], '', $value);
|
||||||
|
if(!ctype_alnum($val)) {
|
||||||
|
return $fail('Username is invalid. Username must be alpha-numeric and may contain dashes (-), periods (.) and underscores (_).');
|
||||||
|
}
|
||||||
|
|
||||||
|
$restricted = RestrictedNames::get();
|
||||||
|
if (in_array(strtolower($value), array_map('strtolower', $restricted))) {
|
||||||
|
return $fail('Username cannot be used.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
$rules = ['username' => $usernameRules];
|
||||||
|
$validator = Validator::make($request->all(), $rules);
|
||||||
|
|
||||||
|
if($validator->fails()) {
|
||||||
|
return response()->json($validator->errors(), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function apiEmailCheck(Request $request)
|
||||||
|
{
|
||||||
|
$this->validate($request, [
|
||||||
|
'token' => 'required',
|
||||||
|
'email' => 'required'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$invite = AdminInvite::whereInviteCode($request->input('token'))->first();
|
||||||
|
abort_if(!$invite, 404);
|
||||||
|
abort_if($invite->expires_at && $invite->expires_at->lt(now()), 400, 'Invite has expired.');
|
||||||
|
abort_if($invite->max_uses && $invite->uses >= $invite->max_uses, 400, 'Maximum invites reached.');
|
||||||
|
|
||||||
|
$emailRules = [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
'unique:users',
|
||||||
|
function ($attribute, $value, $fail) {
|
||||||
|
$banned = EmailService::isBanned($value);
|
||||||
|
if($banned) {
|
||||||
|
return $fail('Email is invalid.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
$rules = ['email' => $emailRules];
|
||||||
|
$validator = Validator::make($request->all(), $rules);
|
||||||
|
|
||||||
|
if($validator->fails()) {
|
||||||
|
return response()->json($validator->errors(), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function apiRegister(Request $request)
|
||||||
|
{
|
||||||
|
$this->validate($request, [
|
||||||
|
'token' => 'required',
|
||||||
|
'username' => 'required',
|
||||||
|
'name' => 'nullable',
|
||||||
|
'email' => 'required|email',
|
||||||
|
'password' => 'required',
|
||||||
|
'password_confirm' => 'required'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$invite = AdminInvite::whereInviteCode($request->input('token'))->firstOrFail();
|
||||||
|
abort_if($invite->expires_at && $invite->expires_at->lt(now()), 400, 'Invite expired');
|
||||||
|
abort_if($invite->max_uses && $invite->uses >= $invite->max_uses, 400, 'Maximum invites reached.');
|
||||||
|
|
||||||
|
$invite->uses = $invite->uses + 1;
|
||||||
|
|
||||||
|
event(new Registered($user = User::create([
|
||||||
|
'name' => $request->input('name') ?? $request->input('username'),
|
||||||
|
'username' => $request->input('username'),
|
||||||
|
'email' => $request->input('email'),
|
||||||
|
'password' => Hash::make($request->input('password')),
|
||||||
|
])));
|
||||||
|
$invite->used_by = array_merge($invite->used_by ?? [], [[
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'username' => $user->username
|
||||||
|
]]);
|
||||||
|
$invite->save();
|
||||||
|
|
||||||
|
if($invite->skip_email_verification) {
|
||||||
|
$user->email_verified_at = now();
|
||||||
|
$user->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
if(Auth::attempt([
|
||||||
|
'email' => $request->input('email'),
|
||||||
|
'password' => $request->input('password')
|
||||||
|
])) {
|
||||||
|
$request->session()->regenerate();
|
||||||
|
return redirect()->intended('/');
|
||||||
|
} else {
|
||||||
|
return response()->json([], 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
21
app/Models/AdminInvite.php
Normal file
21
app/Models/AdminInvite.php
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class AdminInvite extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'used_by' => 'array',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function url()
|
||||||
|
{
|
||||||
|
return url('/auth/invite/a/' . $this->invite_code);
|
||||||
|
}
|
||||||
|
}
|
|
@ -103,4 +103,8 @@ return [
|
||||||
'avatar' => [
|
'avatar' => [
|
||||||
'local_to_cloud' => env('PF_LOCAL_AVATAR_TO_CLOUD', false)
|
'local_to_cloud' => env('PF_LOCAL_AVATAR_TO_CLOUD', false)
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'admin_invites' => [
|
||||||
|
'enabled' => env('PF_ADMIN_INVITES_ENABLED', true)
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Schema::create('admin_invites', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name')->nullable();
|
||||||
|
$table->string('invite_code')->unique()->index();
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->text('message')->nullable();
|
||||||
|
$table->unsignedInteger('max_uses')->nullable();
|
||||||
|
$table->unsignedInteger('uses')->nullable();
|
||||||
|
$table->boolean('skip_email_verification')->default(false);
|
||||||
|
$table->timestamp('expires_at')->nullable();
|
||||||
|
$table->json('used_by')->nullable();
|
||||||
|
$table->unsignedInteger('admin_user_id')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('admin_invites');
|
||||||
|
}
|
||||||
|
};
|
488
resources/assets/components/invite/AdminInvite.vue
Normal file
488
resources/assets/components/invite/AdminInvite.vue
Normal file
|
@ -0,0 +1,488 @@
|
||||||
|
<template>
|
||||||
|
<div class="admin-invite-component">
|
||||||
|
<div class="admin-invite-component-inner">
|
||||||
|
<div class="card bg-dark">
|
||||||
|
<div v-if="tabIndex === 0" class="card-body d-flex align-items-center justify-content-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<b-spinner variant="muted" />
|
||||||
|
<p class="text-muted mb-0">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="tabIndex === 1" class="card-body">
|
||||||
|
<div class="d-flex justify-content-center my-3">
|
||||||
|
<img src="/img/pixelfed-icon-color.png" width="60" alt="Pixelfed logo" />
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-column align-items-center justify-content-center">
|
||||||
|
<p class="lead mb-1 text-muted">You've been invited to join</p>
|
||||||
|
<p class="h3 mb-2">{{ instance.uri }}</p>
|
||||||
|
<p class="mb-0 text-muted">
|
||||||
|
<span>{{ instance.stats.user_count.toLocaleString('en-CA', { compactDisplay: "short", notation: "compact"}) }} users</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{{ instance.stats.status_count.toLocaleString('en-CA', { compactDisplay: "short", notation: "compact"}) }} posts</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="inviteConfig.message != 'You\'ve been invited to join'">
|
||||||
|
<div class="admin-message">
|
||||||
|
<p class="small text-light mb-0">Message from admin(s):</p>
|
||||||
|
{{ inviteConfig.message }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control form-control-lg"
|
||||||
|
placeholder="What should everyone call you?"
|
||||||
|
minlength="2"
|
||||||
|
maxlength="15"
|
||||||
|
v-model="form.username" />
|
||||||
|
|
||||||
|
<p v-if="errors.username" class="form-text text-danger">
|
||||||
|
<i class="far fa-exclamation-triangle mr-1"></i>
|
||||||
|
{{ errors.username }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-primary btn-block font-weight-bold"
|
||||||
|
@click="proceed(tabIndex)"
|
||||||
|
:disabled="isProceeding || !form.username || form.username.length < 2">
|
||||||
|
<template v-if="isProceeding">
|
||||||
|
<b-spinner small />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Continue
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="login-link">
|
||||||
|
<a href="/login">Already have an account?</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="register-terms">
|
||||||
|
By registering, you agree to our <a href="/site/terms">Terms of Service</a> and <a href="/site/privacy">Privacy Policy</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="tabIndex === 2" class="card-body">
|
||||||
|
<div class="d-flex justify-content-center my-3">
|
||||||
|
<img src="/img/pixelfed-icon-color.png" width="60" alt="Pixelfed logo" />
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-column align-items-center justify-content-center">
|
||||||
|
<p class="lead mb-1 text-muted">You've been invited to join</p>
|
||||||
|
<p class="h3 mb-2">{{ instance.uri }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Email Address</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
class="form-control form-control-lg"
|
||||||
|
placeholder="Your email address"
|
||||||
|
v-model="form.email" />
|
||||||
|
|
||||||
|
<p v-if="errors.email" class="form-text text-danger">
|
||||||
|
<i class="far fa-exclamation-triangle mr-1"></i>
|
||||||
|
{{ errors.email }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-primary btn-block font-weight-bold"
|
||||||
|
@click="proceed(tabIndex)"
|
||||||
|
:disabled="isProceeding || !form.email || !validateEmail()">
|
||||||
|
<template v-if="isProceeding">
|
||||||
|
<b-spinner small />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Continue
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="tabIndex === 3" class="card-body">
|
||||||
|
<div class="d-flex justify-content-center my-3">
|
||||||
|
<img src="/img/pixelfed-icon-color.png" width="60" alt="Pixelfed logo" />
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-column align-items-center justify-content-center">
|
||||||
|
<p class="lead mb-1 text-muted">You've been invited to join</p>
|
||||||
|
<p class="h3 mb-2">{{ instance.uri }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
class="form-control form-control-lg"
|
||||||
|
placeholder="Use a secure password"
|
||||||
|
minlength="8"
|
||||||
|
v-model="form.password" />
|
||||||
|
|
||||||
|
<p v-if="errors.password" class="form-text text-danger">
|
||||||
|
<i class="far fa-exclamation-triangle mr-1"></i>
|
||||||
|
{{ errors.password }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-primary btn-block font-weight-bold"
|
||||||
|
@click="proceed(tabIndex)"
|
||||||
|
:disabled="isProceeding || !form.password || form.password.length < 8">
|
||||||
|
<template v-if="isProceeding">
|
||||||
|
<b-spinner small />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Continue
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="tabIndex === 4" class="card-body">
|
||||||
|
<div class="d-flex justify-content-center my-3">
|
||||||
|
<img src="/img/pixelfed-icon-color.png" width="60" alt="Pixelfed logo" />
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-column align-items-center justify-content-center">
|
||||||
|
<p class="lead mb-1 text-muted">You've been invited to join</p>
|
||||||
|
<p class="h3 mb-2">{{ instance.uri }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Confirm Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
class="form-control form-control-lg"
|
||||||
|
placeholder="Use a secure password"
|
||||||
|
minlength="8"
|
||||||
|
v-model="form.password_confirm" />
|
||||||
|
|
||||||
|
<p v-if="errors.password_confirm" class="form-text text-danger">
|
||||||
|
<i class="far fa-exclamation-triangle mr-1"></i>
|
||||||
|
{{ errors.password_confirm }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-primary btn-block font-weight-bold"
|
||||||
|
@click="proceed(tabIndex)"
|
||||||
|
:disabled="isProceeding || !form.password_confirm || form.password !== form.password_confirm">
|
||||||
|
<template v-if="isProceeding">
|
||||||
|
<b-spinner small />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Continue
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="tabIndex === 5" class="card-body">
|
||||||
|
<div class="d-flex justify-content-center my-3">
|
||||||
|
<img src="/img/pixelfed-icon-color.png" width="60" alt="Pixelfed logo" />
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-column align-items-center justify-content-center">
|
||||||
|
<p class="lead mb-1 text-muted">You've been invited to join</p>
|
||||||
|
<p class="h3 mb-2">{{ instance.uri }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Display Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control form-control-lg"
|
||||||
|
placeholder="Add an optional display name"
|
||||||
|
minlength="8"
|
||||||
|
v-model="form.display_name" />
|
||||||
|
|
||||||
|
<p v-if="errors.display_name" class="form-text text-danger">
|
||||||
|
<i class="far fa-exclamation-triangle mr-1"></i>
|
||||||
|
{{ errors.display_name }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-primary btn-block font-weight-bold"
|
||||||
|
@click="proceed(tabIndex)"
|
||||||
|
:disabled="isProceeding">
|
||||||
|
<template v-if="isProceeding">
|
||||||
|
<b-spinner small />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Continue
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="tabIndex === 6" class="card-body d-flex flex-column">
|
||||||
|
<div class="d-flex justify-content-center my-3">
|
||||||
|
<img src="/img/pixelfed-icon-color.png" width="60" alt="Pixelfed logo" />
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-column align-items-center justify-content-center">
|
||||||
|
<p class="lead mb-1 text-muted">You've been invited to join</p>
|
||||||
|
<p class="h3 mb-2">{{ instance.uri }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5 d-flex align-items-center justify-content-center flex-column flex-grow-1">
|
||||||
|
<b-spinner variant="muted" />
|
||||||
|
<p class="text-muted">Registering...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="tabIndex === 'invalid-code'" class="card-body d-flex align-items-center justify-content-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-center">Invalid Invite Code</h1>
|
||||||
|
<hr>
|
||||||
|
<p class="text-muted mb-1">The invite code you were provided is not valid, this can happen when:</p>
|
||||||
|
<ul class="text-muted">
|
||||||
|
<li>Invite code has typos</li>
|
||||||
|
<li>Invite code was already used</li>
|
||||||
|
<li>Invite code has reached max uses</li>
|
||||||
|
<li>Invite code has expired</li>
|
||||||
|
<li>You have been rate limited</li>
|
||||||
|
</ul>
|
||||||
|
<hr>
|
||||||
|
<a href="/" class="btn btn-primary btn-block rounded-pill font-weight-bold">Go back home</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="card-body">
|
||||||
|
<p>An error occured.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
export default {
|
||||||
|
props: ['code'],
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
instance: {},
|
||||||
|
inviteConfig: {},
|
||||||
|
tabIndex: 0,
|
||||||
|
isProceeding: false,
|
||||||
|
errors: {
|
||||||
|
username: undefined,
|
||||||
|
email: undefined,
|
||||||
|
password: undefined,
|
||||||
|
password_confirm: undefined
|
||||||
|
},
|
||||||
|
|
||||||
|
form: {
|
||||||
|
username: undefined,
|
||||||
|
email: undefined,
|
||||||
|
password: undefined,
|
||||||
|
password_confirm: undefined,
|
||||||
|
display_name: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.fetchInstanceData();
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
fetchInstanceData() {
|
||||||
|
axios.get('/api/v1/instance')
|
||||||
|
.then(res => {
|
||||||
|
this.instance = res.data;
|
||||||
|
})
|
||||||
|
.then(res => {
|
||||||
|
this.verifyToken();
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.log(err);
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
verifyToken() {
|
||||||
|
axios.post('/api/v1.1/auth/invite/admin/verify', {
|
||||||
|
token: this.code,
|
||||||
|
})
|
||||||
|
.then(res => {
|
||||||
|
this.tabIndex = 1;
|
||||||
|
this.inviteConfig = res.data;
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
this.tabIndex = 'invalid-code';
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
checkUsernameAvailability() {
|
||||||
|
axios.post('/api/v1.1/auth/invite/admin/uc', {
|
||||||
|
token: this.code,
|
||||||
|
username: this.form.username
|
||||||
|
})
|
||||||
|
.then(res => {
|
||||||
|
if(res && res.data) {
|
||||||
|
this.isProceeding = false;
|
||||||
|
this.tabIndex = 2;
|
||||||
|
} else {
|
||||||
|
this.tabIndex = 'invalid-code';
|
||||||
|
this.isProceeding = false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
if(err.response.data && err.response.data.username) {
|
||||||
|
this.errors.username = err.response.data.username[0];
|
||||||
|
this.isProceeding = false;
|
||||||
|
} else {
|
||||||
|
this.tabIndex = 'invalid-code';
|
||||||
|
this.isProceeding = false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
checkEmailAvailability() {
|
||||||
|
axios.post('/api/v1.1/auth/invite/admin/ec', {
|
||||||
|
token: this.code,
|
||||||
|
email: this.form.email
|
||||||
|
})
|
||||||
|
.then(res => {
|
||||||
|
if(res && res.data) {
|
||||||
|
this.isProceeding = false;
|
||||||
|
this.tabIndex = 3;
|
||||||
|
} else {
|
||||||
|
this.tabIndex = 'invalid-code';
|
||||||
|
this.isProceeding = false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
if(err.response.data && err.response.data.email) {
|
||||||
|
this.errors.email = err.response.data.email[0];
|
||||||
|
this.isProceeding = false;
|
||||||
|
} else {
|
||||||
|
this.tabIndex = 'invalid-code';
|
||||||
|
this.isProceeding = false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
validateEmail() {
|
||||||
|
if(!this.form.email || !this.form.email.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return /^[a-z0-9.]{1,64}@[a-z0-9.]{1,64}$/i.test(this.form.email);
|
||||||
|
},
|
||||||
|
|
||||||
|
handleRegistration() {
|
||||||
|
var $form = $('<form>', {
|
||||||
|
action: '/api/v1.1/auth/invite/admin/re',
|
||||||
|
method: 'post'
|
||||||
|
});
|
||||||
|
let fields = {
|
||||||
|
'_token': document.head.querySelector('meta[name="csrf-token"]').content,
|
||||||
|
token: this.code,
|
||||||
|
username: this.form.username,
|
||||||
|
name: this.form.display_name,
|
||||||
|
email: this.form.email,
|
||||||
|
password: this.form.password,
|
||||||
|
password_confirm: this.form.password_confirm
|
||||||
|
};
|
||||||
|
|
||||||
|
$.each(fields, function(key, val) {
|
||||||
|
$('<input>').attr({
|
||||||
|
type: "hidden",
|
||||||
|
name: key,
|
||||||
|
value: val
|
||||||
|
}).appendTo($form);
|
||||||
|
});
|
||||||
|
$form.appendTo('body').submit();
|
||||||
|
},
|
||||||
|
|
||||||
|
proceed(cur) {
|
||||||
|
this.isProceeding = true;
|
||||||
|
event.currentTarget.blur();
|
||||||
|
|
||||||
|
switch(cur) {
|
||||||
|
case 1:
|
||||||
|
this.checkUsernameAvailability();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 2:
|
||||||
|
this.checkEmailAvailability();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 3:
|
||||||
|
this.isProceeding = false;
|
||||||
|
this.tabIndex = 4;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 4:
|
||||||
|
this.isProceeding = false;
|
||||||
|
this.tabIndex = 5;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 5:
|
||||||
|
this.tabIndex = 6;
|
||||||
|
this.handleRegistration();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.admin-invite-component {
|
||||||
|
font-family: var(--font-family-sans-serif);
|
||||||
|
|
||||||
|
&-inner {
|
||||||
|
display: flex;
|
||||||
|
width: 100wv;
|
||||||
|
height: 100vh;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
color: #fff;
|
||||||
|
padding: 1.25rem 2.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
min-height: 530px;
|
||||||
|
|
||||||
|
@media(min-width: 768px) {
|
||||||
|
width: 30%;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-link {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-terms {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-message {
|
||||||
|
margin-top: 20px;
|
||||||
|
border: 1px solid var(--dropdown-item-hover-color);
|
||||||
|
color: var(--text-lighter);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
4
resources/assets/js/admin_invite.js
vendored
Normal file
4
resources/assets/js/admin_invite.js
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
Vue.component(
|
||||||
|
'admin-invite',
|
||||||
|
require('./../components/invite/AdminInvite.vue').default
|
||||||
|
);
|
21
resources/views/invite/admin_invite.blade.php
Normal file
21
resources/views/invite/admin_invite.blade.php
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
@extends('layouts.blank')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<admin-invite code="{{$code}}" />
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script type="text/javascript" src="{{ mix('js/admin_invite.js') }}"></script>
|
||||||
|
<script type="text/javascript">App.boot();</script>
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@push('styles')
|
||||||
|
<link href="{{ mix('css/spa.css') }}" rel="stylesheet" data-stylesheet="light">
|
||||||
|
<style type="text/css">
|
||||||
|
body {
|
||||||
|
background: #4776E6;
|
||||||
|
background: -webkit-linear-gradient(to right, #8E54E9, #4776E6);
|
||||||
|
background: linear-gradient(to right, #8E54E9, #4776E6);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@endpush
|
|
@ -155,6 +155,10 @@ Route::group(['prefix' => 'api'], function() use($middleware) {
|
||||||
Route::post('iar', 'Api\ApiV1Dot1Controller@inAppRegistration');
|
Route::post('iar', 'Api\ApiV1Dot1Controller@inAppRegistration');
|
||||||
Route::post('iarc', 'Api\ApiV1Dot1Controller@inAppRegistrationConfirm');
|
Route::post('iarc', 'Api\ApiV1Dot1Controller@inAppRegistrationConfirm');
|
||||||
Route::get('iarer', 'Api\ApiV1Dot1Controller@inAppRegistrationEmailRedirect');
|
Route::get('iarer', 'Api\ApiV1Dot1Controller@inAppRegistrationEmailRedirect');
|
||||||
|
|
||||||
|
Route::post('invite/admin/verify', 'AdminInviteController@apiVerifyCheck')->middleware('throttle:20,120');
|
||||||
|
Route::post('invite/admin/uc', 'AdminInviteController@apiUsernameCheck')->middleware('throttle:20,120');
|
||||||
|
Route::post('invite/admin/ec', 'AdminInviteController@apiEmailCheck')->middleware('throttle:10,1440');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -587,6 +587,9 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
|
||||||
Route::get('privacy', 'MobileController@privacy');
|
Route::get('privacy', 'MobileController@privacy');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Route::get('auth/invite/a/{code}', 'AdminInviteController@index');
|
||||||
|
Route::post('api/v1.1/auth/invite/admin/re', 'AdminInviteController@apiRegister')->middleware('throttle:5,1440');
|
||||||
|
|
||||||
Route::get('stories/{username}', 'ProfileController@stories');
|
Route::get('stories/{username}', 'ProfileController@stories');
|
||||||
Route::get('p/{id}', 'StatusController@shortcodeRedirect');
|
Route::get('p/{id}', 'StatusController@shortcodeRedirect');
|
||||||
Route::get('c/{collection}', 'CollectionController@show');
|
Route::get('c/{collection}', 'CollectionController@show');
|
||||||
|
|
Loading…
Reference in a new issue