Browse Source

Add Admin Invites

Daniel Supernault 2 years ago
parent
commit
b73ca9a1ea

+ 163 - 0
app/Console/Commands/AdminInviteCommand.php

@@ -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 - 0
app/Http/Controllers/AdminInviteController.php

@@ -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 - 0
app/Models/AdminInvite.php

@@ -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);
+    }
+}

+ 4 - 0
config/instance.php

@@ -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)
+	],
 ];
 ];

+ 41 - 0
database/migrations/2022_12_13_092726_create_admin_invites_table.php

@@ -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 - 0
resources/assets/components/invite/AdminInvite.vue

@@ -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 - 0
resources/assets/js/admin_invite.js

@@ -0,0 +1,4 @@
+Vue.component(
+    'admin-invite',
+    require('./../components/invite/AdminInvite.vue').default
+);

+ 21 - 0
resources/views/invite/admin_invite.blade.php

@@ -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

+ 4 - 0
routes/api.php

@@ -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');
 		});
 		});
 	});
 	});
 
 

+ 3 - 0
routes/web.php

@@ -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');