Added pinned aliases feature

This commit is contained in:
Will Browning
2026-04-08 13:51:29 +01:00
parent cfb3ecb0ae
commit 89ecdcf76f
21 changed files with 917 additions and 82 deletions

View File

@@ -30,6 +30,8 @@ Vue page components should be placed in the `resources/js/Pages` directory.
### Page Component Structure
Important: Vue components must have a single root element.
<!-- Basic Vue Page Component -->
```vue
<script setup>
@@ -311,29 +313,18 @@ defineProps({
### Polling
Automatically refresh data at intervals:
Use the `usePoll` composable to automatically refresh data at intervals. It handles cleanup on unmount and throttles polling when the tab is inactive.
<!-- Polling Example -->
<!-- Basic Polling -->
```vue
<script setup>
import { router } from '@inertiajs/vue3'
import { onMounted, onUnmounted } from 'vue'
import { usePoll } from '@inertiajs/vue3'
defineProps({
stats: Object
})
let interval
onMounted(() => {
interval = setInterval(() => {
router.reload({ only: ['stats'] })
}, 5000) // Poll every 5 seconds
})
onUnmounted(() => {
clearInterval(interval)
})
usePoll(5000)
</script>
<template>
@@ -344,6 +335,42 @@ onUnmounted(() => {
</template>
```
<!-- Polling With Request Options and Manual Control -->
```vue
<script setup>
import { usePoll } from '@inertiajs/vue3'
defineProps({
stats: Object
})
const { start, stop } = usePoll(5000, {
only: ['stats'],
onStart() {
console.log('Polling request started')
},
onFinish() {
console.log('Polling request finished')
},
}, {
autoStart: false,
keepAlive: true,
})
</script>
<template>
<div>
<h1>Dashboard</h1>
<div>Active Users: {{ stats.activeUsers }}</div>
<button @click="start">Start Polling</button>
<button @click="stop">Stop Polling</button>
</div>
</template>
```
- `autoStart` (default `true`) — set to `false` to start polling manually via the returned `start()` function
- `keepAlive` (default `false`) — set to `true` to prevent throttling when the browser tab is inactive
### WhenVisible
Lazy-load a prop when an element scrolls into view. Useful for deferring expensive data that sits below the fold:
@@ -362,7 +389,6 @@ defineProps({
<div>
<h1>Dashboard</h1>
<!-- stats prop is loaded only when this section scrolls into view -->
<WhenVisible data="stats" :buffer="200">
<template #fallback>
<div class="animate-pulse">Loading stats...</div>
@@ -380,6 +406,31 @@ defineProps({
</template>
```
### InfiniteScroll
Automatically load additional pages of paginated data as users scroll:
<!-- InfiniteScroll Example -->
```vue
<script setup>
import { InfiniteScroll } from '@inertiajs/vue3'
defineProps({
users: Object
})
</script>
<template>
<InfiniteScroll data="users">
<div v-for="user in users.data" :key="user.id">
{{ user.name }}
</div>
</InfiniteScroll>
</template>
```
The server must use `Inertia::scroll()` to configure the paginated data. Use the `search-docs` tool with a query of `infinite scroll` for detailed guidance on buffers, manual loading, reverse mode, and custom trigger elements.
## Server-Side Patterns
Server-side patterns (Inertia::render, props, middleware) are covered in inertia-laravel guidelines.

View File

@@ -30,6 +30,8 @@ Vue page components should be placed in the `resources/js/Pages` directory.
### Page Component Structure
Important: Vue components must have a single root element.
<!-- Basic Vue Page Component -->
```vue
<script setup>
@@ -311,29 +313,18 @@ defineProps({
### Polling
Automatically refresh data at intervals:
Use the `usePoll` composable to automatically refresh data at intervals. It handles cleanup on unmount and throttles polling when the tab is inactive.
<!-- Polling Example -->
<!-- Basic Polling -->
```vue
<script setup>
import { router } from '@inertiajs/vue3'
import { onMounted, onUnmounted } from 'vue'
import { usePoll } from '@inertiajs/vue3'
defineProps({
stats: Object
})
let interval
onMounted(() => {
interval = setInterval(() => {
router.reload({ only: ['stats'] })
}, 5000) // Poll every 5 seconds
})
onUnmounted(() => {
clearInterval(interval)
})
usePoll(5000)
</script>
<template>
@@ -344,6 +335,42 @@ onUnmounted(() => {
</template>
```
<!-- Polling With Request Options and Manual Control -->
```vue
<script setup>
import { usePoll } from '@inertiajs/vue3'
defineProps({
stats: Object
})
const { start, stop } = usePoll(5000, {
only: ['stats'],
onStart() {
console.log('Polling request started')
},
onFinish() {
console.log('Polling request finished')
},
}, {
autoStart: false,
keepAlive: true,
})
</script>
<template>
<div>
<h1>Dashboard</h1>
<div>Active Users: {{ stats.activeUsers }}</div>
<button @click="start">Start Polling</button>
<button @click="stop">Stop Polling</button>
</div>
</template>
```
- `autoStart` (default `true`) — set to `false` to start polling manually via the returned `start()` function
- `keepAlive` (default `false`) — set to `true` to prevent throttling when the browser tab is inactive
### WhenVisible
Lazy-load a prop when an element scrolls into view. Useful for deferring expensive data that sits below the fold:
@@ -362,7 +389,6 @@ defineProps({
<div>
<h1>Dashboard</h1>
<!-- stats prop is loaded only when this section scrolls into view -->
<WhenVisible data="stats" :buffer="200">
<template #fallback>
<div class="animate-pulse">Loading stats...</div>
@@ -380,6 +406,31 @@ defineProps({
</template>
```
### InfiniteScroll
Automatically load additional pages of paginated data as users scroll:
<!-- InfiniteScroll Example -->
```vue
<script setup>
import { InfiniteScroll } from '@inertiajs/vue3'
defineProps({
users: Object
})
</script>
<template>
<InfiniteScroll data="users">
<div v-for="user in users.data" :key="user.id">
{{ user.name }}
</div>
</InfiniteScroll>
</template>
```
The server must use `Inertia::scroll()` to configure the paginated data. Use the `search-docs` tool with a query of `infinite scroll` for detailed guidance on buffers, manual loading, reverse mode, and custom trigger elements.
## Server-Side Patterns
Server-side patterns (Inertia::render, props, middleware) are covered in inertia-laravel guidelines.

View File

@@ -30,6 +30,8 @@ Vue page components should be placed in the `resources/js/Pages` directory.
### Page Component Structure
Important: Vue components must have a single root element.
<!-- Basic Vue Page Component -->
```vue
<script setup>
@@ -311,29 +313,18 @@ defineProps({
### Polling
Automatically refresh data at intervals:
Use the `usePoll` composable to automatically refresh data at intervals. It handles cleanup on unmount and throttles polling when the tab is inactive.
<!-- Polling Example -->
<!-- Basic Polling -->
```vue
<script setup>
import { router } from '@inertiajs/vue3'
import { onMounted, onUnmounted } from 'vue'
import { usePoll } from '@inertiajs/vue3'
defineProps({
stats: Object
})
let interval
onMounted(() => {
interval = setInterval(() => {
router.reload({ only: ['stats'] })
}, 5000) // Poll every 5 seconds
})
onUnmounted(() => {
clearInterval(interval)
})
usePoll(5000)
</script>
<template>
@@ -344,6 +335,42 @@ onUnmounted(() => {
</template>
```
<!-- Polling With Request Options and Manual Control -->
```vue
<script setup>
import { usePoll } from '@inertiajs/vue3'
defineProps({
stats: Object
})
const { start, stop } = usePoll(5000, {
only: ['stats'],
onStart() {
console.log('Polling request started')
},
onFinish() {
console.log('Polling request finished')
},
}, {
autoStart: false,
keepAlive: true,
})
</script>
<template>
<div>
<h1>Dashboard</h1>
<div>Active Users: {{ stats.activeUsers }}</div>
<button @click="start">Start Polling</button>
<button @click="stop">Stop Polling</button>
</div>
</template>
```
- `autoStart` (default `true`) — set to `false` to start polling manually via the returned `start()` function
- `keepAlive` (default `false`) — set to `true` to prevent throttling when the browser tab is inactive
### WhenVisible
Lazy-load a prop when an element scrolls into view. Useful for deferring expensive data that sits below the fold:
@@ -362,7 +389,6 @@ defineProps({
<div>
<h1>Dashboard</h1>
<!-- stats prop is loaded only when this section scrolls into view -->
<WhenVisible data="stats" :buffer="200">
<template #fallback>
<div class="animate-pulse">Loading stats...</div>
@@ -380,6 +406,31 @@ defineProps({
</template>
```
### InfiniteScroll
Automatically load additional pages of paginated data as users scroll:
<!-- InfiniteScroll Example -->
```vue
<script setup>
import { InfiniteScroll } from '@inertiajs/vue3'
defineProps({
users: Object
})
</script>
<template>
<InfiniteScroll data="users">
<div v-for="user in users.data" :key="user.id">
{{ user.name }}
</div>
</InfiniteScroll>
</template>
```
The server must use `Inertia::scroll()` to configure the paginated data. Use the `search-docs` tool with a query of `infinite scroll` for detailed guidance on buffers, manual loading, reverse mode, and custom trigger elements.
## Server-Side Patterns
Server-side patterns (Inertia::render, props, middleware) are covered in inertia-laravel guidelines.

View File

@@ -86,6 +86,44 @@ class AliasBulkController extends Controller
], 200);
}
public function pin(GeneralAliasBulkRequest $request)
{
$aliasIds = user()->aliases()->withTrashed()
->where('pinned', false)
->whereIn('id', $request->ids)
->pluck('id');
if (! $aliasIdsCount = $aliasIds->count()) {
return response()->json(['message' => 'No aliases found'], 404);
}
user()->aliases()->whereIn('id', $aliasIds)->update(['pinned' => true]);
return response()->json([
'message' => $aliasIdsCount === 1 ? '1 alias pinned successfully' : "{$aliasIdsCount} aliases pinned successfully",
'ids' => $aliasIds,
], 200);
}
public function unpin(GeneralAliasBulkRequest $request)
{
$aliasIds = user()->aliases()->withTrashed()
->where('pinned', true)
->whereIn('id', $request->ids)
->pluck('id');
if (! $aliasIdsCount = $aliasIds->count()) {
return response()->json(['message' => 'No aliases found'], 404);
}
user()->aliases()->whereIn('id', $aliasIds)->update(['pinned' => false]);
return response()->json([
'message' => $aliasIdsCount === 1 ? '1 alias unpinned successfully' : "{$aliasIdsCount} aliases unpinned successfully",
'ids' => $aliasIds,
], 200);
}
public function delete(GeneralAliasBulkRequest $request)
{
$aliasIds = user()->aliases()

View File

@@ -26,6 +26,7 @@ class AliasController extends Controller
->when($request->input('username'), function ($query, $id) {
return $query->belongsToAliasable('App\Models\Username', $id);
})
->orderBy('pinned', 'desc')
->when($request->input('sort'), function ($query, $sort) {
$direction = strpos($sort, '-') === 0 ? 'desc' : 'asc';
$sort = ltrim($sort, '-');
@@ -68,6 +69,9 @@ class AliasController extends Controller
$active = $value === 'true' ? true : false;
return $query->where('active', $active);
})
->when($request->input('filter.pinned'), function ($query, $value) {
return $query->where('pinned', $value === 'true');
});
// Keep /aliases?deleted=with for backwards compatibility

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\AliasResource;
use Illuminate\Http\Request;
class PinnedAliasController extends Controller
{
public function store(Request $request)
{
$request->validate(['id' => 'required|string']);
$alias = user()->aliases()->withTrashed()->findOrFail($request->id);
$alias->update(['pinned' => true]);
return new AliasResource($alias->load('recipients'));
}
public function destroy($id)
{
$alias = user()->aliases()->withTrashed()->findOrFail($id);
$alias->update(['pinned' => false]);
return response('', 204);
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\IndexRecipientRequest;
use App\Http\Requests\ShowRecipientRequest;
use App\Http\Requests\StoreRecipientRequest;
use App\Http\Resources\RecipientResource;
@@ -11,7 +12,11 @@ class RecipientController extends Controller
{
public function index(IndexRecipientRequest $request)
{
$recipients = user()->recipients()->withCount('aliases')->latest();
$recipients = user()->recipients()->latest();
if ($request->input('filter.alias_count') !== 'false') {
$recipients->withCount('aliases');
}
if ($request->input('filter.verified') === 'true') {
$recipients->verified();
@@ -24,11 +29,15 @@ class RecipientController extends Controller
return RecipientResource::collection($recipients->get());
}
public function show($id)
public function show(ShowRecipientRequest $request, $id)
{
$recipient = user()->recipients()->findOrFail($id);
return new RecipientResource($recipient->loadCount('aliases'));
if ($request->input('filter.alias_count') !== 'false') {
$recipient->loadCount('aliases');
}
return new RecipientResource($recipient);
}
public function store(StoreRecipientRequest $request)

View File

@@ -83,6 +83,11 @@ class ShowAliasController extends Controller
'nullable',
'uuid',
],
'pinned' => [
'nullable',
'in:true,false,all',
'string',
],
]);
$sort = $request->session()->get('aliasesSort', 'created_at');
@@ -92,6 +97,8 @@ class ShowAliasController extends Controller
// current alias status options: active, inactive, all, deleted, active_inactive
$currentAliasStatus = $request->session()->get('currentAliasStatus', 'active_inactive');
$pinnedFilter = $request->session()->get('aliasesPinnedFilter');
if ($request->has('sort')) {
$direction = strpos($request->input('sort'), '-') === 0 ? 'desc' : 'asc';
$sort = ltrim($request->input('sort'), '-');
@@ -120,8 +127,14 @@ class ShowAliasController extends Controller
$request->session()->put('currentAliasStatus', $currentAliasStatus);
}
if ($request->has('pinned')) {
$pinnedFilter = $request->input('pinned') === 'all' ? null : $request->input('pinned');
$request->session()->put('aliasesPinnedFilter', $pinnedFilter);
}
$aliases = user()->aliases()
->select(['id', 'user_id', 'aliasable_id', 'aliasable_type', 'local_part', 'extension', 'email', 'domain', 'description', 'active', 'emails_forwarded', 'emails_blocked', 'emails_replied', 'emails_sent', 'last_forwarded', 'last_blocked', 'last_replied', 'last_sent', 'created_at', 'deleted_at'])
->select(['id', 'user_id', 'aliasable_id', 'aliasable_type', 'local_part', 'extension', 'email', 'domain', 'description', 'active', 'pinned', 'emails_forwarded', 'emails_blocked', 'emails_replied', 'emails_sent', 'last_forwarded', 'last_blocked', 'last_replied', 'last_sent', 'created_at', 'deleted_at'])
->orderBy('pinned', 'desc')
->when($request->input('recipient'), function ($query, $id) {
return $query->usesRecipientWithId($id, $id === user()->default_recipient_id);
})
@@ -176,6 +189,9 @@ class ShowAliasController extends Controller
return $query->whereNotIn('domain', config('anonaddy.all_domains'));
})
->when($pinnedFilter, function ($query, $value) {
return $query->where('pinned', $value === 'true');
})
->with([
'recipients:id,email',
'aliasable.defaultRecipient:id,email',
@@ -220,6 +236,7 @@ class ShowAliasController extends Controller
'sortDirection' => $direction,
'currentAliasStatus' => $currentAliasStatus,
'sharedDomains' => user()->sharedDomainOptions(),
'pinnedFilter' => $pinnedFilter,
]);
}

View File

@@ -59,6 +59,11 @@ class IndexAliasRequest extends FormRequest
'in:true,false',
'string',
],
'filter.pinned' => [
'nullable',
'in:true,false',
'string',
],
'sort' => [
'nullable',
'max:50',

View File

@@ -33,6 +33,11 @@ class IndexRecipientRequest extends FormRequest
'in:true,false',
'string',
],
'filter.alias_count' => [
'nullable',
'in:true,false',
'string',
],
];
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ShowRecipientRequest 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 [
'filter' => [
'nullable',
'array',
],
'filter.alias_count' => [
'nullable',
'in:true,false',
'string',
],
];
}
}

View File

@@ -18,6 +18,7 @@ class AliasResource extends JsonResource
'domain' => $this->domain,
'email' => $this->email,
'active' => $this->active,
'pinned' => $this->pinned,
'description' => $this->description,
'from_name' => $this->from_name,
'attached_recipients_only' => $this->attached_recipients_only,

View File

@@ -16,6 +16,7 @@ class UserResource extends JsonResource
->selectRaw('ifnull(sum(active=1),0) as active')
->selectRaw('ifnull(sum(CASE WHEN active=0 AND deleted_at IS NULL THEN 1 END),0) as inactive')
->selectRaw('ifnull(sum(CASE WHEN deleted_at IS NOT NULL THEN 1 END),0) as deleted')
->selectRaw('ifnull(sum(pinned=1),0) as pinned')
->selectRaw('ifnull(sum(emails_forwarded),0) as forwarded')
->selectRaw('ifnull(sum(emails_blocked),0) as blocked')
->selectRaw('ifnull(sum(emails_replied),0) as replied')
@@ -49,6 +50,7 @@ class UserResource extends JsonResource
'total_aliases' => (int) $totals->total,
'total_active_aliases' => (int) $totals->active,
'total_inactive_aliases' => (int) $totals->inactive,
'total_pinned_aliases' => (int) $totals->pinned,
'total_deleted_aliases' => (int) $totals->deleted,
'created_at' => $this->created_at->toDateTimeString(),
'updated_at' => $this->updated_at->toDateTimeString(),

View File

@@ -32,6 +32,7 @@ class Alias extends Model
'id',
'user_id',
'active',
'pinned',
'description',
'from_name',
'attached_recipients_only',
@@ -58,6 +59,7 @@ class Alias extends Model
'aliasable_id' => 'string',
'aliasable_type' => 'string',
'active' => 'boolean',
'pinned' => 'boolean',
'attached_recipients_only' => 'boolean',
'last_forwarded' => 'datetime',
'last_blocked' => 'datetime',

View File

@@ -163,7 +163,7 @@ return [
|
*/
'all_domains' => explode(',', env('ANONADDY_ALL_DOMAINS')),
'all_domains' => explode(',', env('ANONADDY_ALL_DOMAINS', '[]')),
/*
|--------------------------------------------------------------------------

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('aliases', function (Blueprint $table) {
$table->boolean('pinned')->after('active')->default(false);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('aliases', function (Blueprint $table) {
$table->dropColumn('pinned');
});
}
};

View File

@@ -286,6 +286,18 @@
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
<svg
v-else-if="name === 'pin'"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<polygon
points="13 8 11 6 11 3 12 3 12 1 4 1 4 3 5 3 5 6 3 8 3 10 7.3 10 7.3 16 8.7 16 8.7 10 13 10 13 8"
/>
</g>
</svg>
<svg
v-else-if="name === 'more'"
xmlns="http://www.w3.org/2000/svg"

View File

@@ -119,6 +119,73 @@
tabindex="-1"
><span class="bg-red-400 h-2 w-2 rounded-full"></span
></span>
<Listbox as="div" v-model="selectedPinnedFilter" class="ml-1">
<div class="relative">
<ListboxButton
class="inline-flex items-center text-sm rounded px-2 py-0.5 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 text-grey-700 hover:text-grey-900 dark:text-grey-200 dark:hover:text-grey-300"
>
<span class="sr-only">Pin filter</span>
<span>{{ selectedPinnedFilter.label }} </span>
<ChevronDownIcon
class="ml-1 h-4 w-4 text-grey-500 dark:text-grey-400"
aria-hidden="true"
/>
<span
class="ml-1 outline-none inline-flex items-center"
:class="
selectedPinnedFilter.value === 'unpinned'
? 'text-grey-500 dark:text-grey-400'
: 'text-yellow-500 dark:text-yellow-400'
"
aria-hidden="true"
>
<icon
name="pin"
class="inline-block w-4 h-4"
:class="
selectedPinnedFilter.value === 'unpinned' ? 'stroke-current' : 'fill-current'
"
/>
</span>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute right-0 left-auto z-20 mt-1 w-40 origin-top-right overflow-hidden rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:bg-grey-900"
>
<ListboxOption
as="template"
v-for="option in pinnedFilterOptions"
:key="option.value"
:value="option"
v-slot="{ active, selected }"
>
<li
:class="[
active ? 'text-white bg-indigo-500' : 'text-grey-900 dark:text-grey-100',
'cursor-pointer select-none p-2 text-sm',
]"
>
<div class="flex justify-between items-center">
<span :class="selected ? 'font-semibold' : 'font-normal'">
{{ option.label }}
</span>
<CheckIcon
v-if="selected"
:class="active ? 'text-white' : 'text-indigo-500 dark:text-grey-100'"
class="h-5 w-5"
aria-hidden="true"
/>
</div>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
</div>
<div class="flex py-4 px-4 sm:px-6 lg:px-8">
<div class="flex items-center">
@@ -218,6 +285,22 @@
>
Deactivate <loader v-if="bulkDeactivateAliasLoading" />
</button>
<button
type="button"
class="inline-flex items-center rounded border border-grey-300 bg-white px-2.5 py-1.5 text-xs font-medium text-grey-700 shadow-sm hover:bg-grey-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-30 dark:border-grey-600 dark:bg-grey-800 dark:text-grey-200 dark:hover:bg-grey-700"
:disabled="disabledBulkPin() || bulkPinAliasLoading"
@click="selectedRows.length === 1 ? pinAlias(selectedRows[0]) : bulkPinAlias()"
>
Pin <loader v-if="bulkPinAliasLoading" />
</button>
<button
type="button"
class="inline-flex items-center rounded border border-grey-300 bg-white px-2.5 py-1.5 text-xs font-medium text-grey-700 shadow-sm hover:bg-grey-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-30 dark:border-grey-600 dark:bg-grey-800 dark:text-grey-200 dark:hover:bg-grey-700"
:disabled="disabledBulkUnpin() || bulkUnpinAliasLoading"
@click="selectedRows.length === 1 ? unpinAlias(selectedRows[0]) : bulkUnpinAlias()"
>
Unpin <loader v-if="bulkUnpinAliasLoading" />
</button>
<button
type="button"
class="inline-flex items-center rounded border border-grey-300 bg-white px-2.5 py-1.5 text-xs font-medium text-grey-700 shadow-sm hover:bg-grey-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-30 dark:border-grey-600 dark:bg-grey-800 dark:text-grey-200 dark:hover:bg-grey-700 whitespace-nowrap"
@@ -296,7 +379,7 @@
<div
v-else
type="checkbox"
class="h-4 w-4 rounded border-grey-300 bg-grey-100 text-indigo-600 focus:ring-indigo-500 sm:left-6 tooltip cursor-not-allowed dark:bg-grey-900"
class="h-4 w-4 rounded border-grey-300 bg-grey-100 border text-indigo-600 focus:ring-indigo-500 sm:left-6 tooltip cursor-not-allowed dark:bg-grey-800"
data-tippy-content="'Select All' is only available when the page size is 25"
></div>
</span>
@@ -353,25 +436,35 @@
</span>
</span>
<span v-else-if="props.column.field == 'email'" class="block">
<button
class="text-grey-400 tooltip outline-none text-left"
data-tippy-content="Click to copy"
@click="clipboard(getAliasEmail(rows[props.row.originalIndex]))"
>
<span class="font-semibold text-indigo-800 dark:text-indigo-400">{{
$filters.truncate(getAliasLocalPart(props.row), 60)
}}</span
><span
v-if="getAliasLocalPart(props.row).length <= 60"
class="font-semibold text-grey-500 dark:text-grey-200"
>{{
$filters.truncate(
'@' + props.row.domain,
60 - getAliasLocalPart(props.row).length,
)
}}</span
<div class="flex items-center">
<span
v-if="props.row.pinned"
class="mr-1 tooltip outline-none inline-flex items-center text-yellow-500 dark:text-yellow-400 cursor-default"
data-tippy-content="Pinned"
aria-hidden="true"
>
</button>
<icon name="pin" class="inline-block w-4 h-4 fill-current" />
</span>
<button
class="text-grey-400 tooltip outline-none"
data-tippy-content="Click to copy"
@click="clipboard(getAliasEmail(rows[props.row.originalIndex]))"
>
<span class="font-semibold text-indigo-800 dark:text-indigo-400">{{
$filters.truncate(getAliasLocalPart(props.row), 60)
}}</span
><span
v-if="getAliasLocalPart(props.row).length <= 60"
class="font-semibold text-grey-500 dark:text-grey-200"
>{{
$filters.truncate(
'@' + props.row.domain,
60 - getAliasLocalPart(props.row).length,
)
}}</span
>
</button>
</div>
<div v-if="aliasIdToEdit === props.row.id" class="flex items-center">
<input
@keyup.enter="editAliasDescription(rows[props.row.originalIndex])"
@@ -1462,6 +1555,10 @@ const props = defineProps({
type: Array,
required: true,
},
pinnedFilter: {
type: String,
default: null,
},
})
const rows = ref(props.initialRows.data)
@@ -1521,6 +1618,8 @@ const tippyInstance = ref(null)
const errors = ref({})
const bulkActivateAliasLoading = ref(false)
const bulkDeactivateAliasLoading = ref(false)
const bulkPinAliasLoading = ref(false)
const bulkUnpinAliasLoading = ref(false)
const bulkEditAliasRecipientsLoading = ref(false)
const bulkEditAliasRecipientsModalOpen = ref(false)
const bulkDeleteAliasLoading = ref(false)
@@ -1580,6 +1679,17 @@ const displayOptions = [
const showAliasStatus = ref(_.find(displayOptions, ['value', props.currentAliasStatus]))
const pinnedFilterOptions = [
{ value: 'all', label: 'All' },
{ value: 'pinned', label: 'Pinned' },
{ value: 'unpinned', label: 'Unpinned' },
]
const getPinnedFilterOption = pinnedFilter => {
const value = pinnedFilter == null ? 'all' : pinnedFilter === 'true' ? 'pinned' : 'unpinned'
return _.find(pinnedFilterOptions, ['value', value]) ?? pinnedFilterOptions[0]
}
const selectedPinnedFilter = ref(getPinnedFilterOption(props.pinnedFilter))
const sortOptions = [
{
value: 'active',
@@ -1721,7 +1831,14 @@ watch(
let params = Object.assign(route().params, status.value.params)
router.visit(route('aliases.index', _.omit(params, status.value.omit)), {
only: ['initialRows', 'search', 'sort', 'sortDirection', 'currentAliasStatus'],
only: [
'initialRows',
'search',
'sort',
'sortDirection',
'currentAliasStatus',
'pinnedFilter',
],
})
},
{ deep: true },
@@ -1735,12 +1852,44 @@ watch(
})
router.visit(route('aliases.index', _.omit(params, ['page'])), {
only: ['initialRows', 'search', 'sort', 'sortDirection', 'currentAliasStatus'],
only: [
'initialRows',
'search',
'sort',
'sortDirection',
'currentAliasStatus',
'pinnedFilter',
],
})
},
{ deep: true },
)
const applyPinnedFilter = value => {
const params = { ...route().params }
params.pinned = value === 'all' ? 'all' : value === 'pinned' ? 'true' : 'false'
router.visit(route('aliases.index', params), {
only: ['initialRows', 'search', 'sort', 'sortDirection', 'currentAliasStatus', 'pinnedFilter'],
})
}
watch(
() => props.pinnedFilter,
val => {
selectedPinnedFilter.value = getPinnedFilterOption(val)
},
)
watch(
selectedPinnedFilter,
(newOpt, oldOpt) => {
if (!oldOpt || newOpt.value === oldOpt.value) return
const currentValue =
props.pinnedFilter == null ? 'all' : props.pinnedFilter === 'true' ? 'pinned' : 'unpinned'
if (newOpt.value === currentValue) return
applyPinnedFilter(newOpt.value)
},
{ deep: true },
)
onMounted(() => {
debounceToolips()
})
@@ -1776,7 +1925,14 @@ const createNewAlias = () => {
.then(({ data }) => {
// Show active/inactive
router.reload({
only: ['initialRows', 'search', 'currentAliasStatus', 'sort', 'sortDirection'],
only: [
'initialRows',
'search',
'currentAliasStatus',
'sort',
'sortDirection',
'pinnedFilter',
],
onSuccess: page => {
rows.value = page.props.initialRows.data
createAliasLoading.value = false
@@ -2025,6 +2181,156 @@ const bulkDeactivateAlias = () => {
})
}
const pinAlias = alias => {
bulkPinAliasLoading.value = true
axios
.post('/api/v1/pinned-aliases', JSON.stringify({ id: alias.id }), {
headers: { 'Content-Type': 'application/json' },
})
.then(() => {
router.reload({
only: [
'initialRows',
'search',
'currentAliasStatus',
'sort',
'sortDirection',
'pinnedFilter',
],
onSuccess: page => {
bulkPinAliasLoading.value = false
selectedRowIds.value = []
rows.value = props.initialRows.data
debounceToolips()
successMessage('Alias pinned')
},
})
})
.catch(error => {
bulkPinAliasLoading.value = false
if (error.response !== undefined) {
errorMessage(error.response.data)
} else {
errorMessage()
}
})
}
const bulkPinAlias = () => {
bulkPinAliasLoading.value = true
let selectedAliasesToPin = _.filter(selectedRows.value, r => !r.pinned)
axios
.post(
'/api/v1/aliases/pin/bulk',
JSON.stringify({ ids: selectedAliasesToPin.map(a => a.id) }),
{ headers: { 'Content-Type': 'application/json' } },
)
.then(response => {
router.reload({
only: [
'initialRows',
'search',
'currentAliasStatus',
'sort',
'sortDirection',
'pinnedFilter',
],
onSuccess: page => {
bulkPinAliasLoading.value = false
selectedRowIds.value = []
rows.value = props.initialRows.data
debounceToolips()
successMessage(response.data.message)
},
})
})
.catch(error => {
bulkPinAliasLoading.value = false
if (error.response?.status === 429) {
errorMessage('Too many bulk requests, please wait a little while before trying again')
} else if (error.response?.data?.message !== undefined) {
errorMessage(error.response.data.message)
} else {
errorMessage()
}
})
}
const unpinAlias = alias => {
bulkUnpinAliasLoading.value = true
axios
.delete(`/api/v1/pinned-aliases/${alias.id}`)
.then(() => {
router.reload({
only: [
'initialRows',
'search',
'currentAliasStatus',
'sort',
'sortDirection',
'pinnedFilter',
],
onSuccess: page => {
bulkUnpinAliasLoading.value = false
selectedRowIds.value = []
rows.value = props.initialRows.data
successMessage('Alias unpinned')
},
})
})
.catch(error => {
bulkUnpinAliasLoading.value = false
if (error.response !== undefined) {
errorMessage(error.response.data)
} else {
errorMessage()
}
})
}
const bulkUnpinAlias = () => {
bulkUnpinAliasLoading.value = true
let selectedAliasesToUnpin = _.filter(selectedRows.value, 'pinned')
axios
.post(
'/api/v1/aliases/unpin/bulk',
JSON.stringify({ ids: selectedAliasesToUnpin.map(a => a.id) }),
{ headers: { 'Content-Type': 'application/json' } },
)
.then(response => {
router.reload({
only: [
'initialRows',
'search',
'currentAliasStatus',
'sort',
'sortDirection',
'pinnedFilter',
],
onSuccess: page => {
bulkUnpinAliasLoading.value = false
selectedRowIds.value = []
rows.value = props.initialRows.data
successMessage(response.data.message)
},
})
})
.catch(error => {
bulkUnpinAliasLoading.value = false
if (error.response?.status === 429) {
errorMessage('Too many bulk requests, please wait a little while before trying again')
} else if (error.response?.data?.message !== undefined) {
errorMessage(error.response.data.message)
} else {
errorMessage()
}
})
}
const deleteAlias = id => {
deleteAliasLoading.value = true
@@ -2046,7 +2352,14 @@ const deleteAlias = id => {
successMessage('Alias deleted successfully')
} else {
router.reload({
only: ['initialRows', 'search', 'currentAliasStatus', 'sort', 'sortDirection'],
only: [
'initialRows',
'search',
'currentAliasStatus',
'sort',
'sortDirection',
'pinnedFilter',
],
onSuccess: page => {
deleteAliasModalOpen.value = false
deleteAliasLoading.value = false
@@ -2092,7 +2405,14 @@ const bulkDeleteAlias = () => {
successMessage(response.data.message)
} else {
router.reload({
only: ['initialRows', 'search', 'currentAliasStatus', 'sort', 'sortDirection'],
only: [
'initialRows',
'search',
'currentAliasStatus',
'sort',
'sortDirection',
'pinnedFilter',
],
onSuccess: page => {
bulkDeleteAliasLoading.value = false
bulkDeleteAliasModalOpen.value = false
@@ -2123,7 +2443,14 @@ const forgetAlias = id => {
.delete(`/api/v1/aliases/${id}/forget`)
.then(response => {
router.reload({
only: ['initialRows', 'search', 'currentAliasStatus', 'sort', 'sortDirection'],
only: [
'initialRows',
'search',
'currentAliasStatus',
'sort',
'sortDirection',
'pinnedFilter',
],
onSuccess: page => {
forgetAliasModalOpen.value = false
forgetAliasLoading.value = false
@@ -2157,7 +2484,14 @@ const bulkForgetAlias = () => {
)
.then(response => {
router.reload({
only: ['initialRows', 'search', 'currentAliasStatus', 'sort', 'sortDirection'],
only: [
'initialRows',
'search',
'currentAliasStatus',
'sort',
'sortDirection',
'pinnedFilter',
],
onSuccess: page => {
bulkForgetAliasLoading.value = false
bulkForgetAliasModalOpen.value = false
@@ -2191,7 +2525,14 @@ const restoreAlias = id => {
// If showing only deleted then reload all aliases
if (props.currentAliasStatus === 'deleted') {
router.reload({
only: ['initialRows', 'search', 'currentAliasStatus', 'sort', 'sortDirection'],
only: [
'initialRows',
'search',
'currentAliasStatus',
'sort',
'sortDirection',
'pinnedFilter',
],
onSuccess: page => {
restoreAliasModalOpen.value = false
restoreAliasLoading.value = false
@@ -2235,7 +2576,14 @@ const bulkRestoreAlias = () => {
// If showing only deleted then reload all aliases
if (props.currentAliasStatus === 'deleted') {
router.reload({
only: ['initialRows', 'search', 'currentAliasStatus', 'sort', 'sortDirection'],
only: [
'initialRows',
'search',
'currentAliasStatus',
'sort',
'sortDirection',
'pinnedFilter',
],
onSuccess: page => {
bulkRestoreAliasLoading.value = false
bulkRestoreAliasModalOpen.value = false
@@ -2276,7 +2624,7 @@ const changeSortDir = () => {
})
router.visit(route(route().current(), _.omit(params, ['page'])), {
only: ['initialRows', 'search', 'currentAliasStatus', 'sort', 'sortDirection'],
only: ['initialRows', 'search', 'currentAliasStatus', 'sort', 'sortDirection', 'pinnedFilter'],
onSuccess: page => {
changeSortDirLoading.value = false
},
@@ -2300,6 +2648,7 @@ const updatePageSize = () => {
'sortDirection',
'currentAliasStatus',
'initialPageSize',
'pinnedFilter',
],
onSuccess: page => {
updatePageSizeLoading.value = false
@@ -2470,6 +2819,14 @@ const disabledBulkDeactivate = () => {
return !_.find(selectedRows.value, 'active')
}
const disabledBulkPin = () => {
return !_.find(selectedRows.value, r => !r.pinned)
}
const disabledBulkUnpin = () => {
return !_.find(selectedRows.value, 'pinned')
}
const disabledBulkDelete = () => {
return !_.find(selectedRows.value, r => {
return r.deleted_at === null

View File

@@ -24,6 +24,7 @@ use App\Http\Controllers\Api\EncryptedRecipientController;
use App\Http\Controllers\Api\FailedDeliveryController;
use App\Http\Controllers\Api\InlineEncryptedRecipientController;
use App\Http\Controllers\Api\LoginableUsernameController;
use App\Http\Controllers\Api\PinnedAliasController;
use App\Http\Controllers\Api\ProtectedHeadersRecipientController;
use App\Http\Controllers\Api\RecipientController;
use App\Http\Controllers\Api\RecipientKeyController;
@@ -79,6 +80,8 @@ Route::group([
Route::post('/aliases/get/bulk', 'get');
Route::post('/aliases/activate/bulk', 'activate');
Route::post('/aliases/deactivate/bulk', 'deactivate');
Route::post('/aliases/pin/bulk', 'pin');
Route::post('/aliases/unpin/bulk', 'unpin');
Route::post('/aliases/delete/bulk', 'delete');
Route::post('/aliases/restore/bulk', 'restore');
Route::post('/aliases/forget/bulk', 'forget');
@@ -90,6 +93,11 @@ Route::group([
Route::delete('/active-aliases/{id}', 'destroy');
});
Route::controller(PinnedAliasController::class)->group(function () {
Route::post('/pinned-aliases', 'store');
Route::delete('/pinned-aliases/{id}', 'destroy');
});
Route::controller(AttachedRecipientOnlyController::class)->group(function () {
Route::post('/attached-recipients-only', 'store');
Route::delete('/attached-recipients-only/{id}', 'destroy');

View File

@@ -103,6 +103,78 @@ class AliasesTest extends TestCase
$this->assertCount(1, $response->json()['data']);
}
#[Test]
public function pinned_aliases_appear_first_when_listing()
{
$unpinnedFirst = Alias::factory()->create([
'user_id' => $this->user->id,
'email' => 'aaa@example.com',
'local_part' => 'aaa',
'domain' => 'example.com',
'pinned' => false,
]);
$pinned = Alias::factory()->create([
'user_id' => $this->user->id,
'email' => 'zzz@example.com',
'local_part' => 'zzz',
'domain' => 'example.com',
'pinned' => true,
]);
$unpinnedSecond = Alias::factory()->create([
'user_id' => $this->user->id,
'email' => 'mmm@example.com',
'local_part' => 'mmm',
'domain' => 'example.com',
'pinned' => false,
]);
$response = $this->json('GET', '/api/v1/aliases');
$response->assertSuccessful();
$data = $response->json()['data'];
$this->assertCount(3, $data);
$this->assertEquals($pinned->id, $data[0]['id']);
$this->assertTrue($data[0]['pinned']);
}
#[Test]
public function user_can_get_only_pinned_aliases()
{
Alias::factory()->create([
'user_id' => $this->user->id,
'pinned' => true,
]);
Alias::factory()->count(2)->create([
'user_id' => $this->user->id,
'pinned' => false,
]);
$response = $this->json('GET', '/api/v1/aliases?filter[pinned]=true');
$response->assertSuccessful();
$this->assertCount(1, $response->json()['data']);
$this->assertTrue($response->json()['data'][0]['pinned']);
}
#[Test]
public function user_can_get_only_unpinned_aliases()
{
Alias::factory()->count(2)->create([
'user_id' => $this->user->id,
'pinned' => true,
]);
Alias::factory()->create([
'user_id' => $this->user->id,
'pinned' => false,
]);
$response = $this->json('GET', '/api/v1/aliases?filter[pinned]=false');
$response->assertSuccessful();
$this->assertCount(1, $response->json()['data']);
$this->assertFalse($response->json()['data'][0]['pinned']);
}
#[Test]
public function user_can_get_individual_alias()
{
@@ -479,6 +551,36 @@ class AliasesTest extends TestCase
$this->assertFalse($this->user->aliases[0]->active);
}
#[Test]
public function user_can_pin_alias()
{
$alias = Alias::factory()->create([
'user_id' => $this->user->id,
'pinned' => false,
]);
$response = $this->json('POST', '/api/v1/pinned-aliases/', [
'id' => $alias->id,
]);
$response->assertStatus(200);
$this->assertEquals(true, $response->getData()->data->pinned);
}
#[Test]
public function user_can_unpin_alias()
{
$alias = Alias::factory()->create([
'user_id' => $this->user->id,
'pinned' => true,
]);
$response = $this->json('DELETE', '/api/v1/pinned-aliases/'.$alias->id);
$response->assertStatus(204);
$this->assertFalse($this->user->aliases[0]->pinned);
}
#[Test]
public function user_can_bulk_get_aliases()
{

View File

@@ -33,6 +33,7 @@ class RecipientsTest extends TestCase
// Assert
$response->assertSuccessful();
$this->assertArrayHasKey('aliases_count', $response->json()['data'][0]);
$this->assertCount(4, $response->json()['data']);
}
@@ -51,6 +52,29 @@ class RecipientsTest extends TestCase
$response->assertSuccessful();
$this->assertCount(1, $response->json());
$this->assertEquals($recipient->email, $response->json()['data']['email']);
$this->assertArrayHasKey('aliases_count', $response->json()['data']);
}
#[Test]
public function recipients_index_omits_alias_count_when_filter_alias_count_is_false()
{
Recipient::factory()->create(['user_id' => $this->user->id]);
$response = $this->json('GET', '/api/v1/recipients?filter[alias_count]=false');
$response->assertSuccessful();
$this->assertArrayNotHasKey('aliases_count', $response->json()['data'][0]);
}
#[Test]
public function recipient_show_omits_alias_count_when_filter_alias_count_is_false()
{
$recipient = Recipient::factory()->create(['user_id' => $this->user->id]);
$response = $this->json('GET', '/api/v1/recipients/'.$recipient->id.'?filter[alias_count]=false');
$response->assertSuccessful();
$this->assertArrayNotHasKey('aliases_count', $response->json()['data']);
}
#[Test]