mirror of
https://github.com/anonaddy/anonaddy
synced 2026-04-25 17:15:29 +02:00
Added pinned aliases feature
This commit is contained in:
@@ -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.
|
||||
|
||||
83
.github/skills/inertia-vue-development/SKILL.md
vendored
83
.github/skills/inertia-vue-development/SKILL.md
vendored
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
30
app/Http/Controllers/Api/PinnedAliasController.php
Normal file
30
app/Http/Controllers/Api/PinnedAliasController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,11 @@ class IndexAliasRequest extends FormRequest
|
||||
'in:true,false',
|
||||
'string',
|
||||
],
|
||||
'filter.pinned' => [
|
||||
'nullable',
|
||||
'in:true,false',
|
||||
'string',
|
||||
],
|
||||
'sort' => [
|
||||
'nullable',
|
||||
'max:50',
|
||||
|
||||
@@ -33,6 +33,11 @@ class IndexRecipientRequest extends FormRequest
|
||||
'in:true,false',
|
||||
'string',
|
||||
],
|
||||
'filter.alias_count' => [
|
||||
'nullable',
|
||||
'in:true,false',
|
||||
'string',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
38
app/Http/Requests/ShowRecipientRequest.php
Normal file
38
app/Http/Requests/ShowRecipientRequest.php
Normal 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',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -163,7 +163,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'all_domains' => explode(',', env('ANONADDY_ALL_DOMAINS')),
|
||||
'all_domains' => explode(',', env('ANONADDY_ALL_DOMAINS', '[]')),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user