diff --git a/.cursor/skills/inertia-vue-development/SKILL.md b/.cursor/skills/inertia-vue-development/SKILL.md index c3f7250..264dbd9 100644 --- a/.cursor/skills/inertia-vue-development/SKILL.md +++ b/.cursor/skills/inertia-vue-development/SKILL.md @@ -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. + ```vue ``` + +```vue + + + +``` + +- `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({

Dashboard

- ``` +### InfiniteScroll + +Automatically load additional pages of paginated data as users scroll: + + +```vue + + + +``` + +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. diff --git a/.github/skills/inertia-vue-development/SKILL.md b/.github/skills/inertia-vue-development/SKILL.md index c3f7250..264dbd9 100644 --- a/.github/skills/inertia-vue-development/SKILL.md +++ b/.github/skills/inertia-vue-development/SKILL.md @@ -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. + ```vue ``` + +```vue + + + +``` + +- `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({

Dashboard

- ``` +### InfiniteScroll + +Automatically load additional pages of paginated data as users scroll: + + +```vue + + + +``` + +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. diff --git a/.junie/skills/inertia-vue-development/SKILL.md b/.junie/skills/inertia-vue-development/SKILL.md index c3f7250..264dbd9 100644 --- a/.junie/skills/inertia-vue-development/SKILL.md +++ b/.junie/skills/inertia-vue-development/SKILL.md @@ -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. + ```vue ``` + +```vue + + + +``` + +- `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({

Dashboard

- ``` +### InfiniteScroll + +Automatically load additional pages of paginated data as users scroll: + + +```vue + + + +``` + +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. diff --git a/app/Http/Controllers/Api/AliasBulkController.php b/app/Http/Controllers/Api/AliasBulkController.php index 2597951..82966e1 100644 --- a/app/Http/Controllers/Api/AliasBulkController.php +++ b/app/Http/Controllers/Api/AliasBulkController.php @@ -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() diff --git a/app/Http/Controllers/Api/AliasController.php b/app/Http/Controllers/Api/AliasController.php index 436c566..1a1b2ba 100644 --- a/app/Http/Controllers/Api/AliasController.php +++ b/app/Http/Controllers/Api/AliasController.php @@ -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 diff --git a/app/Http/Controllers/Api/PinnedAliasController.php b/app/Http/Controllers/Api/PinnedAliasController.php new file mode 100644 index 0000000..6e5993f --- /dev/null +++ b/app/Http/Controllers/Api/PinnedAliasController.php @@ -0,0 +1,30 @@ +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); + } +} diff --git a/app/Http/Controllers/Api/RecipientController.php b/app/Http/Controllers/Api/RecipientController.php index 1fdaf0f..2c86ae0 100644 --- a/app/Http/Controllers/Api/RecipientController.php +++ b/app/Http/Controllers/Api/RecipientController.php @@ -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) diff --git a/app/Http/Controllers/ShowAliasController.php b/app/Http/Controllers/ShowAliasController.php index 690b17a..d28e30f 100644 --- a/app/Http/Controllers/ShowAliasController.php +++ b/app/Http/Controllers/ShowAliasController.php @@ -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, ]); } diff --git a/app/Http/Requests/IndexAliasRequest.php b/app/Http/Requests/IndexAliasRequest.php index 34e8a49..96fe31f 100644 --- a/app/Http/Requests/IndexAliasRequest.php +++ b/app/Http/Requests/IndexAliasRequest.php @@ -59,6 +59,11 @@ class IndexAliasRequest extends FormRequest 'in:true,false', 'string', ], + 'filter.pinned' => [ + 'nullable', + 'in:true,false', + 'string', + ], 'sort' => [ 'nullable', 'max:50', diff --git a/app/Http/Requests/IndexRecipientRequest.php b/app/Http/Requests/IndexRecipientRequest.php index ef56d36..7849e47 100644 --- a/app/Http/Requests/IndexRecipientRequest.php +++ b/app/Http/Requests/IndexRecipientRequest.php @@ -33,6 +33,11 @@ class IndexRecipientRequest extends FormRequest 'in:true,false', 'string', ], + 'filter.alias_count' => [ + 'nullable', + 'in:true,false', + 'string', + ], ]; } } diff --git a/app/Http/Requests/ShowRecipientRequest.php b/app/Http/Requests/ShowRecipientRequest.php new file mode 100644 index 0000000..9552338 --- /dev/null +++ b/app/Http/Requests/ShowRecipientRequest.php @@ -0,0 +1,38 @@ + [ + 'nullable', + 'array', + ], + 'filter.alias_count' => [ + 'nullable', + 'in:true,false', + 'string', + ], + ]; + } +} diff --git a/app/Http/Resources/AliasResource.php b/app/Http/Resources/AliasResource.php index f9464ca..c9f3d8b 100644 --- a/app/Http/Resources/AliasResource.php +++ b/app/Http/Resources/AliasResource.php @@ -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, diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index aae109c..3e1e436 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -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(), diff --git a/app/Models/Alias.php b/app/Models/Alias.php index 42f46db..aafd691 100644 --- a/app/Models/Alias.php +++ b/app/Models/Alias.php @@ -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', diff --git a/config/anonaddy.php b/config/anonaddy.php index 6ee57f2..66cd0b4 100644 --- a/config/anonaddy.php +++ b/config/anonaddy.php @@ -163,7 +163,7 @@ return [ | */ - 'all_domains' => explode(',', env('ANONADDY_ALL_DOMAINS')), + 'all_domains' => explode(',', env('ANONADDY_ALL_DOMAINS', '[]')), /* |-------------------------------------------------------------------------- diff --git a/database/migrations/2026_04_08_102920_add_pinned_to_aliases_table.php b/database/migrations/2026_04_08_102920_add_pinned_to_aliases_table.php new file mode 100644 index 0000000..453379f --- /dev/null +++ b/database/migrations/2026_04_08_102920_add_pinned_to_aliases_table.php @@ -0,0 +1,28 @@ +boolean('pinned')->after('active')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('aliases', function (Blueprint $table) { + $table->dropColumn('pinned'); + }); + } +}; diff --git a/resources/js/Components/Icon.vue b/resources/js/Components/Icon.vue index d305125..1ac75c1 100644 --- a/resources/js/Components/Icon.vue +++ b/resources/js/Components/Icon.vue @@ -286,6 +286,18 @@ + + + + + + +
+ + Pin filter + {{ selectedPinnedFilter.label }} + + + + +
  • +
    + + {{ option.label }} + +
    +
  • +
    +
    +
    +
    +
    @@ -218,6 +285,22 @@ > Deactivate + +
    @@ -353,25 +436,35 @@ - + + + +
    { + 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 diff --git a/routes/api.php b/routes/api.php index 946b0a5..b792a1b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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'); diff --git a/tests/Feature/Api/AliasesTest.php b/tests/Feature/Api/AliasesTest.php index 8107db6..702c572 100644 --- a/tests/Feature/Api/AliasesTest.php +++ b/tests/Feature/Api/AliasesTest.php @@ -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() { diff --git a/tests/Feature/Api/RecipientsTest.php b/tests/Feature/Api/RecipientsTest.php index 49615a6..2ab16bf 100644 --- a/tests/Feature/Api/RecipientsTest.php +++ b/tests/Feature/Api/RecipientsTest.php @@ -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]