浏览代码

Added additional username feature

Will Browning 6 年之前
父节点
当前提交
da29095211

+ 67 - 0
app/AdditionalUsername.php

@@ -0,0 +1,67 @@
+<?php
+
+namespace App;
+
+use App\Traits\HasEncryptedAttributes;
+use App\Traits\HasUuid;
+use Illuminate\Database\Eloquent\Model;
+
+class AdditionalUsername extends Model
+{
+    use HasUuid, HasEncryptedAttributes;
+
+    public $incrementing = false;
+
+    protected $encrypted = [
+        'description'
+    ];
+
+    protected $fillable = [
+        'username',
+        'description',
+        'active'
+    ];
+
+    protected $dates = [
+        'created_at',
+        'updated_at'
+    ];
+
+    protected $casts = [
+        'id' => 'string',
+        'user_id' => 'string',
+        'active' => 'boolean'
+    ];
+
+    /**
+     * Set the username.
+     */
+    public function setUsernameAttribute($value)
+    {
+        $this->attributes['username'] = strtolower($value);
+    }
+
+    /**
+     * Get the user for the additional username.
+     */
+    public function user()
+    {
+        return $this->belongsTo(User::class);
+    }
+
+    /**
+     * Deactivate the username.
+     */
+    public function deactivate()
+    {
+        $this->update(['active' => false]);
+    }
+
+    /**
+     * Activate the username.
+     */
+    public function activate()
+    {
+        $this->update(['active' => true]);
+    }
+}

+ 6 - 0
app/Console/Commands/ReceiveEmail.php

@@ -2,6 +2,7 @@
 
 namespace App\Console\Commands;
 
+use App\AdditionalUsername;
 use App\Alias;
 use App\Domain;
 use App\EmailData;
@@ -97,6 +98,11 @@ class ReceiveEmail extends Command
                         $user = $customDomain->user;
                     }
 
+                    // check if this is an additional username
+                    if ($additionalUsername = AdditionalUsername::where('username', $subdomain)->first()) {
+                        $user = $additionalUsername->user;
+                    }
+
                     // check if this is a uuid generated alias
                     if ($alias = Alias::find($recipient['local_part'])) {
                         $user = $alias->user;

+ 2 - 2
app/Domain.php

@@ -58,7 +58,7 @@ class Domain extends Model
     }
 
     /**
-     * Deactivate the alias.
+     * Deactivate the domain.
      */
     public function deactivate()
     {
@@ -66,7 +66,7 @@ class Domain extends Model
     }
 
     /**
-     * Activate the alias.
+     * Activate the domain.
      */
     public function activate()
     {

+ 27 - 0
app/Http/Controllers/ActiveAdditionalUsernameController.php

@@ -0,0 +1,27 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Http\Resources\AdditionalUsernameResource;
+use Illuminate\Http\Request;
+
+class ActiveAdditionalUsernameController extends Controller
+{
+    public function store(Request $request)
+    {
+        $username = user()->additionalUsernames()->findOrFail($request->id);
+
+        $username->activate();
+
+        return new AdditionalUsernameResource($username);
+    }
+
+    public function destroy($id)
+    {
+        $username = user()->additionalUsernames()->findOrFail($id);
+
+        $username->deactivate();
+
+        return new AdditionalUsernameResource($username);
+    }
+}

+ 51 - 0
app/Http/Controllers/AdditionalUsernameController.php

@@ -0,0 +1,51 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\DeletedUsername;
+use App\Http\Requests\StoreAdditionalUsernameRequest;
+use App\Http\Requests\UpdateAdditionalUsernameRequest;
+use App\Http\Resources\AdditionalUsernameResource;
+
+class AdditionalUsernameController extends Controller
+{
+    public function index()
+    {
+        return view('usernames.index', [
+            'usernames' => user()->additionalUsernames()->latest()->get()
+        ]);
+    }
+
+    public function store(StoreAdditionalUsernameRequest $request)
+    {
+        if (user()->hasReachedAdditionalUsernameLimit()) {
+            return response('', 403);
+        }
+
+        $username = user()->additionalUsernames()->create(['username' => $request->username]);
+
+        user()->increment('username_count');
+
+        return new AdditionalUsernameResource($username->fresh());
+    }
+
+    public function update(UpdateAdditionalUsernameRequest $request, $id)
+    {
+        $username = user()->additionalUsernames()->findOrFail($id);
+
+        $username->update(['description' => $request->description]);
+
+        return new AdditionalUsernameResource($username);
+    }
+
+    public function destroy($id)
+    {
+        $username = user()->additionalUsernames()->findOrFail($id);
+
+        DeletedUsername::create(['username' => $username->username]);
+
+        $username->delete();
+
+        return response('', 204);
+    }
+}

+ 40 - 0
app/Http/Requests/StoreAdditionalUsernameRequest.php

@@ -0,0 +1,40 @@
+<?php
+
+namespace App\Http\Requests;
+
+use App\Rules\NotBlacklisted;
+use App\Rules\NotDeletedUsername;
+use Illuminate\Foundation\Http\FormRequest;
+
+class StoreAdditionalUsernameRequest extends FormRequest
+{
+    /**
+     * Determine if the user is authorized to make this request.
+     *
+     * @return bool
+     */
+    public function authorize()
+    {
+        return true;
+    }
+
+    /**
+     * Get the validation rules that apply to the request.
+     *
+     * @return array
+     */
+    public function rules()
+    {
+        return [
+            'username' => [
+                'required',
+                'alpha_num',
+                'max:20',
+                'unique:users,username',
+                'unique:additional_usernames,username',
+                new NotBlacklisted,
+                new NotDeletedUsername
+            ],
+        ];
+    }
+}

+ 30 - 0
app/Http/Requests/UpdateAdditionalUsernameRequest.php

@@ -0,0 +1,30 @@
+<?php
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+class UpdateAdditionalUsernameRequest extends FormRequest
+{
+    /**
+     * Determine if the user is authorized to make this request.
+     *
+     * @return bool
+     */
+    public function authorize()
+    {
+        return true;
+    }
+
+    /**
+     * Get the validation rules that apply to the request.
+     *
+     * @return array
+     */
+    public function rules()
+    {
+        return [
+            'description' => 'nullable|max:100'
+        ];
+    }
+}

+ 21 - 0
app/Http/Resources/AdditionalUsernameResource.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace App\Http\Resources;
+
+use Illuminate\Http\Resources\Json\JsonResource;
+
+class AdditionalUsernameResource extends JsonResource
+{
+    public function toArray($request)
+    {
+        return [
+            'id' => $this->id,
+            'user_id' => $this->user_id,
+            'username' => $this->username,
+            'description' => $this->description,
+            'active' => $this->active,
+            'created_at' => $this->created_at->toDateTimeString(),
+            'updated_at' => $this->updated_at->toDateTimeString(),
+        ];
+    }
+}

+ 1 - 0
app/Jobs/DeleteAccount.php

@@ -40,6 +40,7 @@ class DeleteAccount implements ShouldQueue
         $this->user->aliases()->delete();
         $this->user->recipients()->get()->each->delete(); // in order to fire deleting model event
         $this->user->domains()->delete();
+        $this->user->additionalUsernames()->delete();
         $this->user->delete();
     }
 }

+ 13 - 0
app/User.php

@@ -147,6 +147,14 @@ class User extends Authenticatable implements MustVerifyEmail
         return $this->hasMany(Domain::class);
     }
 
+    /**
+     * Get all of the user's additional usernames.
+     */
+    public function additionalUsernames()
+    {
+        return $this->hasMany(AdditionalUsername::class);
+    }
+
     /**
      * Get all of the user's verified recipients.
      */
@@ -216,6 +224,11 @@ class User extends Authenticatable implements MustVerifyEmail
                 ->count() >= 10; // TODO update for different plans
     }
 
+    public function hasReachedAdditionalUsernameLimit()
+    {
+        return $this->username_count >= 3;
+    }
+
     public function isVerifiedRecipient($email)
     {
         return $this

+ 5 - 5
composer.lock

@@ -5331,16 +5331,16 @@
         },
         {
             "name": "phpunit/phpunit",
-            "version": "8.3.1",
+            "version": "8.3.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/phpunit.git",
-                "reference": "21461ce5b162d0f1a0fa658e27f975517c5d4234"
+                "reference": "c319d08ebd31e137034c84ad7339054709491485"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/21461ce5b162d0f1a0fa658e27f975517c5d4234",
-                "reference": "21461ce5b162d0f1a0fa658e27f975517c5d4234",
+                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c319d08ebd31e137034c84ad7339054709491485",
+                "reference": "c319d08ebd31e137034c84ad7339054709491485",
                 "shasum": ""
             },
             "require": {
@@ -5410,7 +5410,7 @@
                 "testing",
                 "xunit"
             ],
-            "time": "2019-08-02T07:54:25+00:00"
+            "time": "2019-08-03T15:41:47+00:00"
         },
         {
             "name": "sebastian/code-unit-reverse-lookup",

+ 12 - 0
database/factories/AdditionalUsernameFactory.php

@@ -0,0 +1,12 @@
+<?php
+
+/** @var \Illuminate\Database\Eloquent\Factory $factory */
+
+use Faker\Generator as Faker;
+
+$factory->define(App\AdditionalUsername::class, function (Faker $faker) {
+    return [
+        'user_id' => $faker->uuid,
+        'username' => $faker->userName
+    ];
+});

+ 11 - 0
database/factories/DeletedUsername.php

@@ -0,0 +1,11 @@
+<?php
+
+/** @var \Illuminate\Database\Eloquent\Factory $factory */
+
+use Faker\Generator as Faker;
+
+$factory->define(App\DeletedUsername::class, function (Faker $faker) {
+    return [
+        'username' => $faker->userName,
+    ];
+});

+ 1 - 1
database/factories/UserFactory.php

@@ -16,7 +16,7 @@ use Illuminate\Support\Str;
 
 $factory->define(App\User::class, function (Faker $faker) {
     return [
-        'username' => $faker->username,
+        'username' => $faker->userName,
         'banner_location' => 'top',
         'bandwidth' => 0,
         'default_recipient_id' => function () {

+ 37 - 0
database/migrations/2019_08_05_093129_create_additional_usernames_table.php

@@ -0,0 +1,37 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreateAdditionalUsernamesTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('additional_usernames', function (Blueprint $table) {
+            $table->uuid('id');
+            $table->uuid('user_id');
+            $table->string('username')->unique();
+            $table->text('description')->nullable();
+            $table->boolean('active')->default(true);
+            $table->timestamps();
+
+            $table->primary('id');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('additional_usernames');
+    }
+}

+ 32 - 0
database/migrations/2019_08_05_111548_add_username_count_column_to_users_table.php

@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class AddUsernameCountColumnToUsersTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('users', function (Blueprint $table) {
+            $table->unsignedInteger('username_count')->default(0)->after('bandwidth');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('users', function (Blueprint $table) {
+            $table->dropColumn('username_count');
+        });
+    }
+}

+ 1 - 0
resources/js/app.js

@@ -22,6 +22,7 @@ Vue.component('icon', require('./components/Icon.vue').default)
 Vue.component('aliases', require('./pages/Aliases.vue').default)
 Vue.component('recipients', require('./pages/Recipients.vue').default)
 Vue.component('domains', require('./pages/Domains.vue').default)
+Vue.component('usernames', require('./pages/Usernames.vue').default)
 
 Vue.filter('formatDate', value => {
   return dayjs(value).format('Do MMM YYYY')

+ 2 - 2
resources/js/pages/Domains.vue

@@ -151,7 +151,7 @@
                 class="w-full flex items-center justify-between"
               >
                 <input
-                  @keyup.enter="editAlias(domain)"
+                  @keyup.enter="editDomain(domain)"
                   @keyup.esc="domainIdToEdit = domainDescriptionToEdit = ''"
                   v-model="domainDescriptionToEdit"
                   type="text"
@@ -462,7 +462,7 @@ export default {
           domain.description = this.domainDescriptionToEdit
           this.domainIdToEdit = ''
           this.domainDescriptionToEdit = ''
-          this.success('Alias description updated')
+          this.success('Domain description updated')
         })
         .catch(error => {
           this.domainIdToEdit = ''

+ 538 - 0
resources/js/pages/Usernames.vue

@@ -0,0 +1,538 @@
+<template>
+  <div>
+    <div class="mb-6 flex flex-col md:flex-row justify-between md:items-center">
+      <div class="relative">
+        <input
+          v-model="search"
+          @keyup.esc="search = ''"
+          tabindex="0"
+          type="text"
+          class="w-full md:w-64 appearance-none shadow bg-white text-grey-700 focus:outline-none rounded py-3 pl-3 pr-8"
+          placeholder="Search Usernames"
+        />
+        <icon
+          v-if="search"
+          @click.native="search = ''"
+          name="close-circle"
+          class="absolute right-0 inset-y-0 w-5 h-full text-grey-300 fill-current mr-2 flex items-center cursor-pointer"
+        />
+        <icon
+          v-else
+          name="search"
+          class="absolute right-0 inset-y-0 w-5 h-full text-grey-300 fill-current pointer-events-none mr-2 flex items-center"
+        />
+      </div>
+      <div class="mt-4 md:mt-0">
+        <button
+          @click="addUsernameModalOpen = true"
+          class="bg-cyan-400 hover:bg-cyan-300 text-cyan-900 font-bold py-3 px-4 rounded focus:outline-none ml-auto"
+        >
+          Add Additional Username
+        </button>
+      </div>
+    </div>
+    <div class="bg-white rounded shadow overflow-x-auto">
+      <table v-if="initialUsernames.length" class="w-full whitespace-no-wrap">
+        <tr class="text-left font-semibold text-grey-500 text-sm tracking-wider">
+          <th class="p-4">
+            <div class="flex items-center">
+              Created
+              <div class="inline-flex flex-col">
+                <icon
+                  name="chevron-up"
+                  @click.native="sort('created_at', 'asc')"
+                  class="w-4 h-4 text-grey-300 fill-current cursor-pointer"
+                  :class="{ 'text-grey-800': isCurrentSort('created_at', 'asc') }"
+                />
+                <icon
+                  name="chevron-down"
+                  @click.native="sort('created_at', 'desc')"
+                  class="w-4 h-4 text-grey-300 fill-current cursor-pointer"
+                  :class="{
+                    'text-grey-800': isCurrentSort('created_at', 'desc'),
+                  }"
+                />
+              </div>
+            </div>
+          </th>
+          <th class="p-4">
+            <div class="flex items-center">
+              Username
+              <div class="inline-flex flex-col">
+                <icon
+                  name="chevron-up"
+                  @click.native="sort('username', 'asc')"
+                  class="w-4 h-4 text-grey-300 fill-current cursor-pointer"
+                  :class="{ 'text-grey-800': isCurrentSort('v', 'asc') }"
+                />
+                <icon
+                  name="chevron-down"
+                  @click.native="sort('username', 'desc')"
+                  class="w-4 h-4 text-grey-300 fill-current cursor-pointer"
+                  :class="{ 'text-grey-800': isCurrentSort('username', 'desc') }"
+                />
+              </div>
+            </div>
+          </th>
+          <th class="p-4">
+            <div class="flex items-center">
+              Description
+            </div>
+          </th>
+          <th class="p-4 items-center" colspan="2">
+            <div class="flex items-center">
+              Active
+              <div class="inline-flex flex-col">
+                <icon
+                  name="chevron-up"
+                  @click.native="sort('active', 'asc')"
+                  class="w-4 h-4 text-grey-300 fill-current cursor-pointer"
+                  :class="{ 'text-grey-800': isCurrentSort('active', 'asc') }"
+                />
+                <icon
+                  name="chevron-down"
+                  @click.native="sort('active', 'desc')"
+                  class="w-4 h-4 text-grey-300 fill-current cursor-pointer"
+                  :class="{ 'text-grey-800': isCurrentSort('active', 'desc') }"
+                />
+              </div>
+            </div>
+          </th>
+        </tr>
+        <tr
+          v-for="username in queriedUsernames"
+          :key="username.id"
+          class="hover:bg-grey-50 focus-within:bg-grey-50 h-20"
+        >
+          <td class="border-grey-200 border-t">
+            <div class="p-4 flex items-center">
+              <span
+                class="tooltip outline-none text-sm"
+                :data-tippy-content="username.created_at | formatDate"
+                >{{ username.created_at | timeAgo }}</span
+              >
+            </div>
+          </td>
+          <td class="border-grey-200 border-t">
+            <div class="p-4 flex items-center focus:text-indigo-500">
+              <span
+                class="tooltip cursor-pointer outline-none"
+                data-tippy-content="Click to copy"
+                v-clipboard="() => username.username"
+                v-clipboard:success="clipboardSuccess"
+                v-clipboard:error="clipboardError"
+                >{{ username.username | truncate(30) }}</span
+              >
+            </div>
+          </td>
+          <td class="border-grey-200 border-t w-64">
+            <div class="p-4 text-sm">
+              <div
+                v-if="usernameIdToEdit === username.id"
+                class="w-full flex items-center justify-between"
+              >
+                <input
+                  @keyup.enter="editUsername(username)"
+                  @keyup.esc="usernameIdToEdit = usernameDescriptionToEdit = ''"
+                  v-model="usernameDescriptionToEdit"
+                  type="text"
+                  class="appearance-none bg-grey-100 border text-grey-700 focus:outline-none rounded px-2 py-1"
+                  :class="
+                    usernameDescriptionToEdit.length > 100 ? 'border-red-500' : 'border-transparent'
+                  "
+                  placeholder="Add description"
+                  tabindex="0"
+                  autofocus
+                />
+                <icon
+                  name="close"
+                  class="inline-block w-6 h-6 text-grey-200 fill-current cursor-pointer"
+                  @click.native="usernameIdToEdit = usernameDescriptionToEdit = ''"
+                />
+                <icon
+                  name="save"
+                  class="inline-block w-6 h-6 text-grey-200 fill-current cursor-pointer"
+                  @click.native="editUsername(username)"
+                />
+              </div>
+              <div
+                v-else-if="username.description"
+                class="flex items-center justify-between w-full"
+              >
+                <span class="tooltip outline-none" :data-tippy-content="username.description">{{
+                  username.description | truncate(25)
+                }}</span>
+                <icon
+                  name="edit"
+                  class="inline-block w-6 h-6 text-grey-200 fill-current cursor-pointer"
+                  @click.native="
+                    ;(usernameIdToEdit = username.id),
+                      (usernameDescriptionToEdit = username.description)
+                  "
+                />
+              </div>
+              <div v-else class="w-full flex justify-center">
+                <icon
+                  name="plus"
+                  class="block w-6 h-6 text-grey-200 fill-current cursor-pointer"
+                  @click.native="usernameIdToEdit = username.id"
+                />
+              </div>
+            </div>
+          </td>
+          <td class="border-grey-200 border-t">
+            <div class="p-4 flex items-center">
+              <Toggle
+                v-model="username.active"
+                @on="activateUsername(username)"
+                @off="deactivateUsername(username)"
+              />
+            </div>
+          </td>
+          <td class="border-grey-200 border-t w-px">
+            <div
+              class="px-4 flex items-center cursor-pointer outline-none focus:text-indigo-500"
+              @click="openDeleteModal(username.id)"
+              tabindex="-1"
+            >
+              <icon name="trash" class="block w-6 h-6 text-grey-200 fill-current" />
+            </div>
+          </td>
+        </tr>
+        <tr v-if="queriedUsernames.length === 0">
+          <td
+            class="border-grey-200 border-t p-4 text-center h-24 text-lg text-grey-700"
+            colspan="4"
+          >
+            No usernames found for that search!
+          </td>
+        </tr>
+      </table>
+
+      <div v-else class="p-8 text-center text-lg text-grey-700">
+        <h1 class="mb-6 text-xl text-indigo-800 font-semibold">
+          This is where you can add and view additional usernames
+        </h1>
+        <div class="mx-auto mb-6 w-24 border-b-2 border-grey-200"></div>
+        <p class="mb-4">
+          When you add an additional username here you will be able to use it exactly like the
+          username you signed up with!
+        </p>
+        <p class="mb-4">
+          You can then separate aliases under your different usernames to reduce the chance of
+          anyone linking ownership of them together.
+        </p>
+        <p class="mb-4">
+          You can add a maximum of 3 additional usernames.
+        </p>
+        <p>
+          If you add 3 usernames here and then delete one, you will not be able to re-add that
+          username or add any others so please choose carefully.
+        </p>
+      </div>
+    </div>
+
+    <Modal :open="addUsernameModalOpen" @close="addUsernameModalOpen = false">
+      <div class="max-w-lg w-full bg-white rounded-lg shadow-2xl p-6">
+        <h2
+          class="font-semibold text-grey-900 text-2xl leading-tight border-b-2 border-grey-100 pb-4"
+        >
+          Add new additional username
+        </h2>
+        <p class="mt-4 text-grey-700">
+          Please choose additional usernames carefully as you can only add a maximum of three.
+        </p>
+        <div class="mt-6">
+          <p v-show="errors.newUsername" class="mb-3 text-red-500">
+            {{ errors.newUsername }}
+          </p>
+          <input
+            v-model="newUsername"
+            type="text"
+            class="w-full appearance-none bg-grey-100 border border-transparent text-grey-700 focus:outline-none rounded p-3 mb-6"
+            :class="errors.newUsername ? 'border-red-500' : ''"
+            placeholder="johndoe"
+            autofocus
+          />
+          <button
+            @click="validateNewUsername"
+            class="bg-cyan-400 hover:bg-cyan-300 text-cyan-900 font-bold py-3 px-4 rounded focus:outline-none"
+            :class="addUsernameLoading ? 'cursor-not-allowed' : ''"
+            :disabled="addUsernameLoading"
+          >
+            Add Username
+          </button>
+          <button
+            @click="addUsernameModalOpen = false"
+            class="ml-4 px-4 py-3 text-grey-800 font-semibold bg-white hover:bg-grey-50 border border-grey-100 rounded focus:outline-none"
+          >
+            Cancel
+          </button>
+        </div>
+      </div>
+    </Modal>
+
+    <Modal :open="deleteUsernameModalOpen" @close="closeDeleteModal">
+      <div class="max-w-lg w-full bg-white rounded-lg shadow-2xl p-6">
+        <h2
+          class="font-semibold text-grey-900 text-2xl leading-tight border-b-2 border-grey-100 pb-4"
+        >
+          Delete username
+        </h2>
+        <p class="mt-4 text-grey-700">
+          Are you sure you want to delete this username? You will no longer be able to receive any
+          emails at this username subdomain. This will still count towards your additional username
+          limit even once deleted.
+        </p>
+        <div class="mt-6">
+          <button
+            type="button"
+            @click="deleteUsername(usernameIdToDelete)"
+            class="px-4 py-3 text-white font-semibold bg-red-500 hover:bg-red-600 border border-transparent rounded focus:outline-none"
+            :class="deleteUsernameLoading ? 'cursor-not-allowed' : ''"
+            :disabled="deleteUsernameLoading"
+          >
+            Delete username
+          </button>
+          <button
+            @click="closeDeleteModal"
+            class="ml-4 px-4 py-3 text-grey-800 font-semibold bg-white hover:bg-grey-50 border border-grey-100 rounded focus:outline-none"
+          >
+            Cancel
+          </button>
+        </div>
+      </div>
+    </Modal>
+  </div>
+</template>
+
+<script>
+import Modal from './../components/Modal.vue'
+import Toggle from './../components/Toggle.vue'
+import tippy from 'tippy.js'
+
+export default {
+  props: {
+    initialUsernames: {
+      type: Array,
+      required: true,
+    },
+  },
+  components: {
+    Modal,
+    Toggle,
+  },
+  mounted() {
+    this.addTooltips()
+  },
+  data() {
+    return {
+      usernames: this.initialUsernames,
+      newUsername: '',
+      search: '',
+      addUsernameLoading: false,
+      addUsernameModalOpen: false,
+      usernameIdToDelete: null,
+      usernameIdToEdit: '',
+      usernameDescriptionToEdit: '',
+      deleteUsernameLoading: false,
+      deleteUsernameModalOpen: false,
+      currentSort: 'created_at',
+      currentSortDir: 'desc',
+      errors: {},
+    }
+  },
+  watch: {
+    queriedUsernames: _.debounce(function() {
+      this.addTooltips()
+    }, 50),
+    usernameIdToEdit: _.debounce(function() {
+      this.addTooltips()
+    }, 50),
+  },
+  computed: {
+    queriedUsernames() {
+      return _.filter(this.usernames, username => username.username.includes(this.search))
+    },
+  },
+  methods: {
+    addTooltips() {
+      tippy('.tooltip', {
+        arrow: true,
+        arrowType: 'round',
+      })
+    },
+    isCurrentSort(col, dir) {
+      return this.currentSort === col && this.currentSortDir === dir
+    },
+    validateNewUsername(e) {
+      this.errors = {}
+
+      if (!this.newUsername) {
+        this.errors.newUsername = 'Username is required'
+      } else if (!this.validUsername(this.newUsername)) {
+        this.errors.newUsername = 'Username must only contain letters and numbers'
+      } else if (this.newUsername.length > 20) {
+        this.errors.newUsername = 'Username cannot be greater than 20 characters'
+      }
+
+      if (!this.errors.newUsername) {
+        this.addNewUsername()
+      }
+
+      e.preventDefault()
+    },
+    addNewUsername() {
+      this.addUsernameLoading = true
+
+      axios
+        .post(
+          '/usernames',
+          JSON.stringify({
+            username: this.newUsername,
+          }),
+          {
+            headers: { 'Content-Type': 'application/json' },
+          }
+        )
+        .then(({ data }) => {
+          this.addUsernameLoading = false
+          this.usernames.push(data.data)
+          this.reSort()
+          this.newUsername = ''
+          this.addUsernameModalOpen = false
+          this.success('Additional Username added')
+        })
+        .catch(error => {
+          this.addUsernameLoading = false
+
+          if (error.response.status === 403) {
+            this.error('You have reached your additional username limit')
+          } else if (error.response.status == 422) {
+            this.error(error.response.data.errors.username[0])
+          } else {
+            this.error()
+          }
+        })
+    },
+    openDeleteModal(id) {
+      this.deleteUsernameModalOpen = true
+      this.usernameIdToDelete = id
+    },
+    closeDeleteModal() {
+      this.deleteUsernameModalOpen = false
+      this.usernameIdToDelete = null
+    },
+    editUsername(username) {
+      if (this.usernameDescriptionToEdit.length > 100) {
+        return this.error('Description cannot be more than 100 characters')
+      }
+
+      axios
+        .patch(
+          `/usernames/${username.id}`,
+          JSON.stringify({
+            description: this.usernameDescriptionToEdit,
+          }),
+          {
+            headers: { 'Content-Type': 'application/json' },
+          }
+        )
+        .then(response => {
+          username.description = this.usernameDescriptionToEdit
+          this.usernameIdToEdit = ''
+          this.usernameDescriptionToEdit = ''
+          this.success('Username description updated')
+        })
+        .catch(error => {
+          this.usernameIdToEdit = ''
+          this.usernameDescriptionToEdit = ''
+          this.error()
+        })
+    },
+    activateUsername(username) {
+      axios
+        .post(
+          `/active-usernames`,
+          JSON.stringify({
+            id: username.id,
+          }),
+          {
+            headers: { 'Content-Type': 'application/json' },
+          }
+        )
+        .then(response => {
+          //
+        })
+        .catch(error => {
+          this.error()
+        })
+    },
+    deactivateUsername(username) {
+      axios
+        .delete(`/active-usernames/${username.id}`)
+        .then(response => {
+          //
+        })
+        .catch(error => {
+          this.error()
+        })
+    },
+    deleteUsername(id) {
+      this.deleteUsernameLoading = true
+
+      axios
+        .delete(`/usernames/${id}`)
+        .then(response => {
+          this.usernames = _.filter(this.usernames, username => username.id !== id)
+          this.deleteUsernameModalOpen = false
+          this.deleteUsernameLoading = false
+        })
+        .catch(error => {
+          this.error()
+          this.deleteUsernameLoading = false
+          this.deleteUsernameModalOpen = false
+        })
+    },
+    sort(col, dir) {
+      if (this.currentSort === col && this.currentSortDir === dir) {
+        this.currentSort = 'created_at'
+        this.currentSortDir = 'desc'
+      } else {
+        this.currentSort = col
+        this.currentSortDir = dir
+      }
+
+      this.reSort()
+    },
+    reSort() {
+      this.usernames = _.orderBy(this.usernames, [this.currentSort], [this.currentSortDir])
+    },
+    validUsername(username) {
+      let re = /^[a-zA-Z0-9]*$/
+      return re.test(username)
+    },
+    clipboardSuccess() {
+      this.success('Copied to clipboard')
+    },
+
+    clipboardError() {
+      this.error('Could not copy to clipboard')
+    },
+    success(text = '') {
+      this.$notify({
+        title: 'Success',
+        text: text,
+        type: 'success',
+      })
+    },
+    error(text = 'An error has occurred, please try again later') {
+      this.$notify({
+        title: 'Error',
+        text: text,
+        type: 'error',
+      })
+    },
+  },
+}
+</script>

+ 3 - 0
resources/views/nav/nav.blade.php

@@ -21,6 +21,9 @@
                     <a href="{{ route('domains.index') }}" class="block mt-4 md:inline-block md:mt-0 hover:text-white mr-4 {{ Route::currentRouteNamed('domains.index') ? 'text-white' : 'text-indigo-100' }}">
                         Domains
                     </a>
+                    <a href="{{ route('usernames.index') }}" class="block mt-4 md:inline-block md:mt-0 hover:text-white mr-4 {{ Route::currentRouteNamed('usernames.index') ? 'text-white' : 'text-indigo-100' }}">
+                        Usernames
+                    </a>
 
                     <a href="{{ route('settings.show') }}" class="block md:hidden mt-4 hover:text-white mr-4 {{ Route::currentRouteNamed('settings.show') ? 'text-white' : 'text-indigo-100' }}">
                         Settings

+ 9 - 0
resources/views/usernames/index.blade.php

@@ -0,0 +1,9 @@
+@extends('layouts.app')
+
+@section('content')
+    <div class="container py-8">
+        @include('shared.status')
+
+        <usernames :initial-usernames="{{json_encode($usernames)}}" />
+    </div>
+@endsection

+ 8 - 0
routes/web.php

@@ -46,6 +46,14 @@ Route::middleware(['auth', 'verified', '2fa'])->group(function () {
     Route::post('/active-domains', 'ActiveDomainController@store')->name('active_domains.store');
     Route::delete('/active-domains/{id}', 'ActiveDomainController@destroy')->name('active_domains.destroy');
 
+    Route::get('/usernames', 'AdditionalUsernameController@index')->name('usernames.index');
+    Route::post('/usernames', 'AdditionalUsernameController@store')->name('usernames.store');
+    Route::patch('/usernames/{id}', 'AdditionalUsernameController@update')->name('usernames.update');
+    Route::delete('/usernames/{id}', 'AdditionalUsernameController@destroy')->name('usernames.destroy');
+
+    Route::post('/active-usernames', 'ActiveAdditionalUsernameController@store')->name('active_usernames.store');
+    Route::delete('/active-usernames/{id}', 'ActiveAdditionalUsernameController@destroy')->name('active_usernames.destroy');
+
     Route::get('/deactivate/{alias}', 'DeactivateAliasController@deactivate')->name('deactivate');
 });
 

+ 231 - 0
tests/Feature/AdditionalUsernamesTest.php

@@ -0,0 +1,231 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\AdditionalUsername;
+use App\DeletedUsername;
+use App\User;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Carbon;
+use Tests\TestCase;
+
+class AdditionalUsernamesTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected $user;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->user = factory(User::class)->create();
+        $this->actingAs($this->user);
+    }
+
+    /** @test */
+    public function user_can_view_usernames_from_the_usernames_page()
+    {
+        $usernames = factory(AdditionalUsername::class, 3)->create([
+            'user_id' => $this->user->id
+        ]);
+
+        $response = $this->get('/usernames');
+
+        $response->assertSuccessful();
+        $this->assertCount(3, $response->data('usernames'));
+        $usernames->assertEquals($response->data('usernames'));
+    }
+
+    /** @test */
+    public function latest_usernames_are_listed_first()
+    {
+        $a = factory(AdditionalUsername::class)->create([
+            'user_id' => $this->user->id,
+            'created_at' => Carbon::now()->subDays(15)
+        ]);
+        $b = factory(AdditionalUsername::class)->create([
+            'user_id' => $this->user->id,
+            'created_at' => Carbon::now()->subDays(5)
+        ]);
+        $c = factory(AdditionalUsername::class)->create([
+            'user_id' => $this->user->id,
+            'created_at' => Carbon::now()->subDays(10)
+        ]);
+
+        $response = $this->get('/usernames');
+
+        $response->assertSuccessful();
+        $this->assertCount(3, $response->data('usernames'));
+        $this->assertTrue($response->data('usernames')[0]->is($b));
+        $this->assertTrue($response->data('usernames')[1]->is($c));
+        $this->assertTrue($response->data('usernames')[2]->is($a));
+    }
+
+    /** @test */
+    public function user_can_create_additional_username()
+    {
+        $response = $this->json('POST', '/usernames', [
+            'username' => 'janedoe'
+        ]);
+
+        $response->assertStatus(200);
+        $this->assertEquals('janedoe', $response->getData()->data->username);
+        $this->assertEquals(1, $this->user->username_count);
+    }
+
+    /** @test */
+    public function user_can_not_exceed_additional_username_limit()
+    {
+        $this->json('POST', '/usernames', [
+            'username' => 'username1'
+        ]);
+
+        $this->json('POST', '/usernames', [
+            'username' => 'username2'
+        ]);
+
+        $this->json('POST', '/usernames', [
+            'username' => 'username3'
+        ]);
+
+        $response = $this->json('POST', '/usernames', [
+            'username' => 'janedoe'
+        ]);
+
+        $response->assertStatus(403);
+        $this->assertEquals(3, $this->user->username_count);
+        $this->assertCount(3, $this->user->additionalUsernames);
+    }
+
+    /** @test */
+    public function user_can_not_create_the_same_username()
+    {
+        factory(AdditionalUsername::class)->create([
+            'user_id' => $this->user->id,
+            'username' => 'janedoe'
+        ]);
+
+        $response = $this->json('POST', '/usernames', [
+            'username' => 'janedoe'
+        ]);
+
+        $response
+            ->assertStatus(422)
+            ->assertJsonValidationErrors('username');
+    }
+
+    /** @test */
+    public function user_can_not_create_additional_username_that_has_been_deleted()
+    {
+        factory(DeletedUsername::class)->create([
+            'username' => 'janedoe'
+        ]);
+
+        $response = $this->json('POST', '/usernames', [
+            'username' => 'janedoe'
+        ]);
+
+        $response
+            ->assertStatus(422)
+            ->assertJsonValidationErrors('username');
+    }
+
+    /** @test */
+    public function must_be_unique_across_users_and_additional_usernames_tables()
+    {
+        $user = factory(User::class)->create();
+
+        $response = $this->json('POST', '/usernames', [
+            'username' => $user->username
+        ]);
+
+        $response
+            ->assertStatus(422)
+            ->assertJsonValidationErrors('username');
+    }
+
+    /** @test */
+    public function additional_username_must_be_alpha_numeric()
+    {
+        $response = $this->json('POST', '/usernames', [
+            'username' => 'username01_'
+        ]);
+
+        $response
+            ->assertStatus(422)
+            ->assertJsonValidationErrors('username');
+    }
+
+    /** @test */
+    public function additional_username_must_be_less_than_max_length()
+    {
+        $response = $this->json('POST', '/usernames', [
+            'username' => 'abcdefghijklmnopqrstu'
+        ]);
+
+        $response
+            ->assertStatus(422)
+            ->assertJsonValidationErrors('username');
+    }
+
+    /** @test */
+    public function user_can_activate_additional_username()
+    {
+        $username = factory(AdditionalUsername::class)->create([
+            'user_id' => $this->user->id,
+            'active' => false
+        ]);
+
+        $response = $this->json('POST', '/active-usernames/', [
+            'id' => $username->id
+        ]);
+
+        $response->assertStatus(200);
+        $this->assertEquals(true, $response->getData()->data->active);
+    }
+
+    /** @test */
+    public function user_can_deactivate_additional_username()
+    {
+        $username = factory(AdditionalUsername::class)->create([
+            'user_id' => $this->user->id,
+            'active' => true
+        ]);
+
+        $response = $this->json('DELETE', '/active-usernames/'.$username->id);
+
+        $response->assertStatus(200);
+        $this->assertEquals(false, $response->getData()->data->active);
+    }
+
+    /** @test */
+    public function user_can_update_additional_usernames_description()
+    {
+        $username = factory(AdditionalUsername::class)->create([
+            'user_id' => $this->user->id
+        ]);
+
+        $response = $this->json('PATCH', '/usernames/'.$username->id, [
+            'description' => 'The new description'
+        ]);
+
+        $response->assertStatus(200);
+        $this->assertEquals('The new description', $response->getData()->data->description);
+    }
+
+    /** @test */
+    public function user_can_delete_additional_username()
+    {
+        $username = factory(AdditionalUsername::class)->create([
+            'user_id' => $this->user->id
+        ]);
+
+        $response = $this->json('DELETE', '/usernames/'.$username->id);
+
+        $response->assertStatus(204);
+        $this->assertEmpty($this->user->additionalUsernames);
+
+        $this->assertEquals(DeletedUsername::first()->username, $username->username);
+    }
+}

+ 2 - 1
tests/Feature/AliasesTest.php

@@ -150,7 +150,8 @@ class AliasesTest extends TestCase
 
         $response->assertStatus(200);
         $this->assertCount(1, $this->user->aliases);
-        $this->assertEquals($this->user->aliases[0]->email, $response->getData()->data->email);
+        $this->assertEquals($this->user->aliases[0]->id, $response->getData()->data->local_part);
+        $this->assertEquals($this->user->aliases[0]->id, $this->user->aliases[0]->local_part);
     }
 
     /** @test */

+ 0 - 1
tests/Feature/DomainsTest.php

@@ -64,7 +64,6 @@ class DomainsTest extends TestCase
     /** @test */
     public function user_can_create_new_domain()
     {
-        $this->withoutExceptionHandling();
         $response = $this->json('POST', '/domains', [
             'domain' => 'example.com'
         ]);

+ 45 - 0
tests/Feature/ReceiveEmailTest.php

@@ -2,6 +2,7 @@
 
 namespace Tests\Feature;
 
+use App\AdditionalUsername;
 use App\Alias;
 use App\AliasRecipient;
 use App\Domain;
@@ -764,6 +765,50 @@ class ReceiveEmailTest extends TestCase
         });
     }
 
+    /** @test */
+    public function it_can_forward_email_for_additional_username()
+    {
+        Mail::fake();
+
+        Mail::assertNothingSent();
+
+        factory(AdditionalUsername::class)->create([
+            'user_id' => $this->user->id,
+            'username' => 'janedoe'
+        ]);
+
+        $this->artisan(
+            'anonaddy:receive-email',
+            [
+                'file' => base_path('tests/emails/email_additional_username.eml'),
+                '--sender' => 'will@anonaddy.com',
+                '--recipient' => ['ebay@janedoe.anonaddy.com'],
+                '--local_part' => ['ebay'],
+                '--extension' => [''],
+                '--domain' => ['janedoe.anonaddy.com'],
+                '--size' => '638'
+            ]
+        )->assertExitCode(0);
+
+        $this->assertDatabaseHas('aliases', [
+            'email' => 'ebay@janedoe.anonaddy.com',
+            'local_part' => 'ebay',
+            'domain' => 'janedoe.anonaddy.com',
+            'emails_forwarded' => 1,
+            'emails_blocked' => 0
+        ]);
+        $this->assertDatabaseHas('users', [
+            'id' => $this->user->id,
+            'username' => 'johndoe',
+            'bandwidth' => '638'
+        ]);
+        $this->assertEquals(1, $this->user->aliases()->count());
+
+        Mail::assertQueued(ForwardEmail::class, function ($mail) {
+            return $mail->hasTo($this->user->email);
+        });
+    }
+
     /** @test */
     public function it_can_send_near_bandwidth_limit_notification()
     {

+ 28 - 0
tests/emails/email_additional_username.eml

@@ -0,0 +1,28 @@
+Date: Wed, 20 Feb 2019 15:00:00 +0100 (CET)
+From: Will <will@anonaddy.com>
+To: <ebay@janedoe.anonaddy.com>
+Subject: Test Email
+Content-Type: multipart/mixed; boundary="----=_Part_10031_1199410393.1550677940425"
+
+------=_Part_10031_1199410393.1550677940425
+Content-Type: text/html; charset=UTF-8
+Content-Transfer-Encoding: quoted-printable
+
+Hi,<br>
+<br>
+This is a test email.<br>
+<br>
+Will
+
+
+------=_Part_10031_1199410393.1550677940425
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: quoted-printable
+
+Hi,
+
+This is a test email.
+
+Will
+
+------=_Part_10031_1199410393.1550677940425--