mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-17 18:23:19 +01:00
Compare commits
11 Commits
1edb940557
...
feature/st
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95bd15864a | ||
|
|
8f446306ef | ||
|
|
e74af760dd | ||
|
|
425175c43e | ||
|
|
28aba7b27a | ||
|
|
453d5ed066 | ||
|
|
ed3ba0135b | ||
|
|
fb7d945c50 | ||
|
|
9958e1c09c | ||
|
|
f00d48f1c3 | ||
|
|
7edbfd5d76 |
1
.github/VOUCHED.td
vendored
1
.github/VOUCHED.td
vendored
@@ -14,6 +14,7 @@
|
||||
# Seeded 2026-07-25 from the authors of every merged pull request.
|
||||
|
||||
agross
|
||||
akolenda
|
||||
bufferhead-code
|
||||
candideu
|
||||
kasparrosin
|
||||
|
||||
@@ -51,7 +51,8 @@ class TimeEntrySendStillRunningMailsCommand extends Command
|
||||
])
|
||||
->whereHas('user', function (Builder $query): void {
|
||||
/** @var Builder<User> $query */
|
||||
$query->where('is_placeholder', '=', false);
|
||||
$query->where('is_placeholder', '=', false)
|
||||
->where('send_time_entry_still_running_email', '=', true);
|
||||
})
|
||||
->orderBy('created_at', 'asc')
|
||||
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void {
|
||||
|
||||
@@ -28,7 +28,9 @@ class ExportController extends Controller
|
||||
|
||||
$filepath = $exportService->export($organization);
|
||||
$downloadUrl = Storage::disk(config('filesystems.private'))
|
||||
->temporaryUrl($filepath, Carbon::now()->addMinutes(10));
|
||||
->temporaryUrl($filepath, Carbon::now()->addMinutes(10), [
|
||||
'ResponseContentDisposition' => 'attachment; filename="'.basename($filepath).'"',
|
||||
]);
|
||||
|
||||
return new JsonResponse([
|
||||
'success' => true,
|
||||
|
||||
@@ -336,7 +336,9 @@ class TimeEntryController extends Controller
|
||||
|
||||
return response()->json([
|
||||
'download_url' => Storage::disk(config('filesystems.private'))
|
||||
->temporaryUrl($path, now()->addMinutes(5)),
|
||||
->temporaryUrl($path, now()->addMinutes(5), [
|
||||
'ResponseContentDisposition' => 'attachment; filename="'.$filename.'"',
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -547,7 +549,9 @@ class TimeEntryController extends Controller
|
||||
|
||||
return response()->json([
|
||||
'download_url' => Storage::disk(config('filesystems.private'))
|
||||
->temporaryUrl($path, now()->addMinutes(5)),
|
||||
->temporaryUrl($path, now()->addMinutes(5), [
|
||||
'ResponseContentDisposition' => 'attachment; filename="'.$filename.'"',
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +124,10 @@ class UserController extends Controller
|
||||
$user->week_start = $request->getWeekStart();
|
||||
}
|
||||
|
||||
if ($request->getSendTimeEntryStillRunningEmail() !== null) {
|
||||
$user->send_time_entry_still_running_email = $request->getSendTimeEntryStillRunningEmail();
|
||||
}
|
||||
|
||||
$user->save();
|
||||
|
||||
if ($emailToVerify !== null) {
|
||||
|
||||
@@ -58,6 +58,9 @@ class UserUpdateRequest extends BaseFormRequest
|
||||
'week_start' => [
|
||||
Rule::enum(Weekday::class),
|
||||
],
|
||||
'send_time_entry_still_running_email' => [
|
||||
'boolean',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -81,6 +84,13 @@ class UserUpdateRequest extends BaseFormRequest
|
||||
return $this->has('week_start') ? Weekday::from($this->input('week_start')) : null;
|
||||
}
|
||||
|
||||
public function getSendTimeEntryStillRunningEmail(): ?bool
|
||||
{
|
||||
return $this->has('send_time_entry_still_running_email')
|
||||
? $this->boolean('send_time_entry_still_running_email')
|
||||
: null;
|
||||
}
|
||||
|
||||
public function hasPhotoKey(): bool
|
||||
{
|
||||
return $this->has('photo');
|
||||
|
||||
@@ -36,6 +36,8 @@ class UserResource extends BaseResource
|
||||
'timezone' => $this->resource->timezone,
|
||||
/** @var Weekday $week_start Starting day of the week */
|
||||
'week_start' => $this->resource->week_start->value,
|
||||
/** @var bool $send_time_entry_still_running_email Whether to email the user when a time entry has been running for more than 8 hours */
|
||||
'send_time_entry_still_running_email' => $this->resource->send_time_entry_still_running_email,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property string $timezone
|
||||
* @property bool $is_placeholder
|
||||
* @property Weekday $week_start
|
||||
* @property bool $send_time_entry_still_running_email
|
||||
* @property string|null $profile_photo_path
|
||||
* @property-read Organization|null $currentOrganization
|
||||
* @property-read string $profile_photo_url
|
||||
@@ -108,6 +109,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
||||
'is_admin' => 'boolean',
|
||||
'is_placeholder' => 'boolean',
|
||||
'week_start' => Weekday::class,
|
||||
'send_time_entry_still_running_email' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -117,6 +119,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
||||
*/
|
||||
protected $attributes = [
|
||||
'week_start' => Weekday::Monday,
|
||||
'send_time_entry_still_running_email' => true,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,7 @@ use App\Service\BillingContract;
|
||||
use App\Service\IpLookup\IpLookupServiceContract;
|
||||
use App\Service\IpLookup\NoIpLookupService;
|
||||
use App\Service\PermissionStore;
|
||||
use DateTimeInterface;
|
||||
use Dedoc\Scramble\Scramble;
|
||||
use Dedoc\Scramble\Support\Generator\OpenApi;
|
||||
use Dedoc\Scramble\Support\Generator\SecurityScheme;
|
||||
@@ -29,8 +30,13 @@ use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -98,6 +104,27 @@ class AppServiceProvider extends ServiceProvider
|
||||
$this->app->bind(IpLookupServiceContract::class, NoIpLookupService::class);
|
||||
$this->app->bind(BillingContract::class);
|
||||
|
||||
// Storage
|
||||
// The local driver ignores the ResponseContentDisposition option of temporaryUrl,
|
||||
// so mirror it through the signed query parameters of the storage route.
|
||||
$privateDisk = config('filesystems.private');
|
||||
if (config('filesystems.disks.'.$privateDisk.'.driver') === 'local') {
|
||||
$disk = Storage::disk($privateDisk);
|
||||
$disk->serveUsing(function (Request $request, string $path, array $headers) use ($disk): StreamedResponse {
|
||||
return $disk->response($path, null, $headers, $request->query('disposition', 'inline'));
|
||||
});
|
||||
$disk->buildTemporaryUrlsUsing(function (string $path, DateTimeInterface $expiration, array $options) use ($privateDisk): string {
|
||||
$parameters = array_filter([
|
||||
'path' => $path,
|
||||
'disposition' => isset($options['ResponseContentDisposition'])
|
||||
? Str::before($options['ResponseContentDisposition'], ';')
|
||||
: null,
|
||||
]);
|
||||
|
||||
return url(URL::temporarySignedRoute('storage.'.$privateDisk, $expiration, $parameters, absolute: false));
|
||||
});
|
||||
}
|
||||
|
||||
// Routing
|
||||
Route::model('member', Member::class);
|
||||
Route::model('invitation', OrganizationInvitation::class);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
$table->boolean('send_time_entry_still_running_email')->default(true)->after('week_start');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
$table->dropColumn('send_time_entry_still_running_email');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -20,6 +20,12 @@ function profileInformationForm(page: Page) {
|
||||
.locator('xpath=ancestor::*[descendant::form][1]');
|
||||
}
|
||||
|
||||
function notificationSettingsForm(page: Page) {
|
||||
return page
|
||||
.getByRole('heading', { name: 'Notifications', exact: true })
|
||||
.locator('xpath=ancestor::*[descendant::form][1]');
|
||||
}
|
||||
|
||||
async function saveProfileForm(page: Page): Promise<void> {
|
||||
const form = profileInformationForm(page);
|
||||
await form.getByRole('button', { name: 'Save' }).click();
|
||||
@@ -50,6 +56,22 @@ test('week-start change persists across reload', async ({ page }) => {
|
||||
await expect(page.getByLabel('Start of the week')).toHaveValue('sunday');
|
||||
});
|
||||
|
||||
test('still-running email notification setting persists across reload', async ({ page }) => {
|
||||
await goToProfilePage(page);
|
||||
const form = notificationSettingsForm(page);
|
||||
const checkbox = form.getByLabel('Still-running time entry reminders');
|
||||
|
||||
await expect(checkbox).toBeChecked();
|
||||
await checkbox.uncheck();
|
||||
await form.getByRole('button', { name: 'Save' }).click();
|
||||
await expect(form.getByText('Saved.', { exact: true })).toBeVisible();
|
||||
|
||||
await page.reload();
|
||||
await expect(
|
||||
notificationSettingsForm(page).getByLabel('Still-running time entry reminders')
|
||||
).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('profile photo can be uploaded, persists across reload, and can be removed', async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1288,3 +1288,36 @@ test.describe('Projects Pagination', () => {
|
||||
await expect(page.getByText(prefix + '00')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test('test that searching projects by name works', async ({ page, ctx }) => {
|
||||
const suffix = Math.floor(1 + Math.random() * 10000);
|
||||
const matchingProjectName = 'Searchable Project ' + suffix;
|
||||
const otherProjectName = 'Unrelated Work ' + suffix;
|
||||
await createProjectViaApi(ctx, { name: matchingProjectName });
|
||||
await createProjectViaApi(ctx, { name: otherProjectName });
|
||||
|
||||
await goToProjectsOverview(page);
|
||||
await clearTableState(page, 'project-table-state');
|
||||
await page.reload();
|
||||
await expect(page.getByText(matchingProjectName)).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText(otherProjectName)).toBeVisible();
|
||||
|
||||
const searchInput = page.getByRole('searchbox', { name: 'Search projects' });
|
||||
|
||||
// Searching is case insensitive and matches part of the name
|
||||
await searchInput.fill('SEARCHABLE');
|
||||
await expect(page.getByText(matchingProjectName)).toBeVisible();
|
||||
await expect(page.getByText(otherProjectName)).not.toBeVisible();
|
||||
|
||||
// A term that matches nothing empties the table
|
||||
await searchInput.fill('no project has this name');
|
||||
await expect(page.getByText(matchingProjectName)).not.toBeVisible();
|
||||
await expect(page.getByText(otherProjectName)).not.toBeVisible();
|
||||
await expect(page.getByText('No matching projects')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Create your First Project' })).not.toBeVisible();
|
||||
|
||||
// Clearing the search restores both projects
|
||||
await searchInput.fill('');
|
||||
await expect(page.getByText(matchingProjectName)).toBeVisible();
|
||||
await expect(page.getByText(otherProjectName)).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"Billing": {
|
||||
"repository": "solidtime-io/extension-billing",
|
||||
"ref": "v0.0.5"
|
||||
"ref": "v0.0.6"
|
||||
},
|
||||
"Services": {
|
||||
"repository": "solidtime-io/extension-services",
|
||||
|
||||
@@ -7,6 +7,7 @@ import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue
|
||||
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
|
||||
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
|
||||
import Pagination from '@/Components/Common/Pagination.vue';
|
||||
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
||||
|
||||
export type SortColumn =
|
||||
| 'name'
|
||||
@@ -34,12 +35,17 @@ import {
|
||||
|
||||
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
|
||||
|
||||
const props = defineProps<{
|
||||
projects: Project[];
|
||||
showBillableRate: boolean;
|
||||
sortColumn: SortColumn;
|
||||
sortDirection: SortDirection;
|
||||
}>();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
projects: Project[];
|
||||
showBillableRate: boolean;
|
||||
sortColumn: SortColumn;
|
||||
sortDirection: SortDirection;
|
||||
isFiltered?: boolean;
|
||||
isLoading?: boolean;
|
||||
}>(),
|
||||
{ isFiltered: false, isLoading: false }
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
sort: [column: SortColumn, direction: SortDirection];
|
||||
@@ -128,6 +134,28 @@ const paginatedProjects = computed(() => {
|
||||
return sortedProjects.value.slice(start, start + PAGE_SIZE);
|
||||
});
|
||||
|
||||
const emptyState = computed(() => {
|
||||
if (props.isFiltered) {
|
||||
return {
|
||||
title: 'No matching projects',
|
||||
description: 'Try a different search term or adjust your filters.',
|
||||
showCreateButton: false,
|
||||
};
|
||||
}
|
||||
if (!canCreateProjects()) {
|
||||
return {
|
||||
title: 'You are not a member of any projects',
|
||||
description: 'Ask your manager to add you to a project as a team member.',
|
||||
showCreateButton: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: 'No projects found',
|
||||
description: 'Create your first project now!',
|
||||
showCreateButton: true,
|
||||
};
|
||||
});
|
||||
|
||||
const showCreateProjectModal = ref(false);
|
||||
|
||||
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
|
||||
@@ -161,24 +189,21 @@ const gridTemplate = computed(() => {
|
||||
:sort-direction="props.sortDirection"
|
||||
:desc-first-columns="descFirstColumns"
|
||||
@sort="handleSort"></ProjectTableHeading>
|
||||
<div v-if="sortedProjects.length === 0" class="col-span-full py-24 text-center">
|
||||
<div
|
||||
v-if="props.isLoading"
|
||||
class="col-span-full flex justify-center items-center py-24">
|
||||
<LoadingSpinner></LoadingSpinner>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="sortedProjects.length === 0"
|
||||
class="col-span-full py-24 text-center">
|
||||
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
|
||||
<h3 class="text-text-primary font-semibold">
|
||||
{{
|
||||
canCreateProjects()
|
||||
? 'No projects found'
|
||||
: 'You are not a member of any projects'
|
||||
}}
|
||||
</h3>
|
||||
<h3 class="text-text-primary font-semibold">{{ emptyState.title }}</h3>
|
||||
<p class="pb-5 max-w-md mx-auto text-sm pt-1">
|
||||
{{
|
||||
canCreateProjects()
|
||||
? 'Create your first project now!'
|
||||
: 'Ask your manager to add you to a project as a team member.'
|
||||
}}
|
||||
{{ emptyState.description }}
|
||||
</p>
|
||||
<SecondaryButton
|
||||
v-if="canCreateProjects()"
|
||||
v-if="emptyState.showCreateButton"
|
||||
:icon="PlusIcon"
|
||||
@click="showCreateProjectModal = true"
|
||||
>Create your First Project
|
||||
|
||||
@@ -30,7 +30,7 @@ function handleSort(column: SortColumn) {
|
||||
<template>
|
||||
<TableHeading>
|
||||
<SortableTableHeaderCell
|
||||
class="pr-3 pl-4 sm:pl-6 lg:pl-8 3xl:pl-12"
|
||||
class="pr-3 pl-2 sm:pl-4 lg:pl-6"
|
||||
column="name"
|
||||
v-bind="sortState"
|
||||
@sort="handleSort">
|
||||
@@ -58,7 +58,7 @@ function handleSort(column: SortColumn) {
|
||||
<SortableTableHeaderCell column="visibility" v-bind="sortState" @sort="handleSort">
|
||||
Visibility
|
||||
</SortableTableHeaderCell>
|
||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||
<div class="relative py-1.5 pl-3 pr-2 sm:pr-4 lg:pr-6">
|
||||
<span class="sr-only">Edit</span>
|
||||
</div>
|
||||
</TableHeading>
|
||||
|
||||
@@ -88,13 +88,13 @@ const showEditProjectModal = ref(false);
|
||||
<ContextMenuTrigger as-child>
|
||||
<TableRow :href="route('projects.show', { project: project.id })">
|
||||
<div
|
||||
class="whitespace-nowrap min-w-0 flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
class="whitespace-nowrap min-w-0 flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-text-primary pl-2 sm:pl-4 lg:pl-6">
|
||||
<div
|
||||
:style="{
|
||||
backgroundColor: project.color,
|
||||
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) ${project.color}30`,
|
||||
}"
|
||||
class="w-3 h-3 rounded-full"></div>
|
||||
class="w-3 h-3 ml-1 rounded-full"></div>
|
||||
<span class="overflow-ellipsis overflow-hidden">
|
||||
{{ project.name }}
|
||||
</span>
|
||||
@@ -155,7 +155,7 @@ const showEditProjectModal = ref(false);
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-2 sm:pr-4 lg:pr-6">
|
||||
<ProjectMoreOptionsDropdown
|
||||
:project="project"
|
||||
@edit="showEditProjectModal = true"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import { UserGroupIcon, CheckCircleIcon, GlobeAltIcon } from '@heroicons/vue/16/solid';
|
||||
import ListFilterIcon from '@/packages/ui/src/Icons/ListFilterIcon.vue';
|
||||
import {
|
||||
@@ -81,23 +81,18 @@ function toggleNoClient() {
|
||||
clientIds,
|
||||
});
|
||||
}
|
||||
|
||||
const hasActiveFilters = computed(() => {
|
||||
return (
|
||||
props.filters.status !== 'all' ||
|
||||
props.filters.visibility !== 'all' ||
|
||||
props.filters.clientIds.length > 0
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenu v-model:open="open">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="xs" aria-label="Filter projects">
|
||||
<ListFilterIcon
|
||||
:class="[hasActiveFilters ? '' : '-ml-0.5', 'h-4 w-4 text-icon-default']" />
|
||||
<span v-if="!hasActiveFilters" class="text-nowrap">Filter</span>
|
||||
<!-- -ml-1.5 cancels the icon's inset inside the button so it sits flush with the toolbar's left padding -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="-ml-1.5 h-7 w-7 flex-shrink-0"
|
||||
aria-label="Filter projects">
|
||||
<ListFilterIcon class="h-4 w-4 text-icon-default" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" class="w-56">
|
||||
|
||||
@@ -9,7 +9,7 @@ const showExportModal = defineModel('show', { default: false });
|
||||
|
||||
function downloadCurrentExport() {
|
||||
if (props.exportUrl) {
|
||||
window.open(props.exportUrl, '_blank')?.focus();
|
||||
window.open(props.exportUrl, '_self');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
103
resources/js/Pages/Profile/Partials/NotificationSettingsForm.vue
Normal file
103
resources/js/Pages/Profile/Partials/NotificationSettingsForm.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import ActionMessage from '@/Components/ActionMessage.vue';
|
||||
import FormSection from '@/Components/FormSection.vue';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import { Checkbox } from '@/packages/ui/src';
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
} from '@/packages/ui/src/field';
|
||||
import { getApiValidationFieldErrors } from '@/utils/apiValidation';
|
||||
import { useUpdateUserMutation, useUserQuery } from '@/utils/useUserQuery';
|
||||
|
||||
const { user } = useUserQuery();
|
||||
const updateUser = useUpdateUserMutation();
|
||||
|
||||
const sendTimeEntryStillRunningEmail = ref(true);
|
||||
const recentlySaved = ref(false);
|
||||
|
||||
watch(
|
||||
user,
|
||||
(currentUser, previousUser) => {
|
||||
if (currentUser && previousUser === undefined) {
|
||||
sendTimeEntryStillRunningEmail.value = currentUser.send_time_entry_still_running_email;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const isUserLoaded = computed(() => user.value !== undefined);
|
||||
const isSaveDisabled = computed(() => !isUserLoaded.value || updateUser.isPending.value);
|
||||
const fieldErrors = computed<Record<string, string>>(() =>
|
||||
getApiValidationFieldErrors(updateUser.error.value)
|
||||
);
|
||||
|
||||
async function save() {
|
||||
if (isSaveDisabled.value || !user.value) return;
|
||||
|
||||
if (sendTimeEntryStillRunningEmail.value === user.value.send_time_entry_still_running_email) {
|
||||
flashSaved();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedUser = await updateUser.mutateAsync({
|
||||
userId: user.value.id,
|
||||
body: {
|
||||
send_time_entry_still_running_email: sendTimeEntryStillRunningEmail.value,
|
||||
},
|
||||
});
|
||||
sendTimeEntryStillRunningEmail.value = updatedUser.send_time_entry_still_running_email;
|
||||
flashSaved();
|
||||
} catch {
|
||||
// 422: field errors render below. Other errors: toast handled in the mutation.
|
||||
}
|
||||
}
|
||||
|
||||
function flashSaved() {
|
||||
recentlySaved.value = true;
|
||||
setTimeout(() => (recentlySaved.value = false), 2000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormSection @submitted="save">
|
||||
<template #title>Notifications</template>
|
||||
|
||||
<template #description>Choose which email notifications you want to receive.</template>
|
||||
|
||||
<template #form>
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id="send_time_entry_still_running_email"
|
||||
v-model:checked="sendTimeEntryStillRunningEmail"
|
||||
:disabled="!isUserLoaded" />
|
||||
<FieldContent>
|
||||
<FieldLabel for="send_time_entry_still_running_email">
|
||||
Still-running time entry reminders
|
||||
</FieldLabel>
|
||||
<FieldDescription>
|
||||
Email me when a time entry has been running for more than 8 hours.
|
||||
</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<FieldError v-if="fieldErrors.send_time_entry_still_running_email">
|
||||
{{ fieldErrors.send_time_entry_still_running_email }}
|
||||
</FieldError>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<ActionMessage :on="recentlySaved" class="me-3">Saved.</ActionMessage>
|
||||
|
||||
<PrimaryButton :class="{ 'opacity-25': isSaveDisabled }" :disabled="isSaveDisabled">
|
||||
Save
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</FormSection>
|
||||
</template>
|
||||
@@ -9,6 +9,7 @@ import UpdateProfileInformationForm from '@/Pages/Profile/Partials/UpdateProfile
|
||||
import type { Session } from '@/types/jetstream';
|
||||
import ApiTokensForm from '@/Pages/Profile/Partials/ApiTokensForm.vue';
|
||||
import ThemeForm from '@/Pages/Profile/Partials/ThemeForm.vue';
|
||||
import NotificationSettingsForm from '@/Pages/Profile/Partials/NotificationSettingsForm.vue';
|
||||
|
||||
defineProps<{
|
||||
confirmsTwoFactorAuthentication: boolean;
|
||||
@@ -36,6 +37,12 @@ defineProps<{
|
||||
<SectionBorder />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NotificationSettingsForm />
|
||||
|
||||
<SectionBorder />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<UpdatePasswordForm class="mt-10 sm:mt-0" />
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||
import { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid';
|
||||
import { Search } from '@lucide/vue';
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import ProjectTable from '@/Components/Common/Project/ProjectTable.vue';
|
||||
import { computed } from 'vue';
|
||||
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
||||
@@ -27,7 +29,7 @@ import { NO_CLIENT_ID } from '@/Components/Common/Project/constants';
|
||||
import type { SortColumn, SortDirection } from '@/Components/Common/Project/ProjectTable.vue';
|
||||
|
||||
// Fetch data using TanStack Query
|
||||
const { projects } = useProjectsQuery();
|
||||
const { projects, isLoading: projectsLoading } = useProjectsQuery();
|
||||
const { clients } = useClientsQuery();
|
||||
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
|
||||
|
||||
@@ -62,9 +64,19 @@ const { tableState, handleSort } = useTableSortState<SortColumn, ProjectTableSta
|
||||
})
|
||||
);
|
||||
|
||||
// Not persisted, so a reload never starts silently filtered
|
||||
const search = ref('');
|
||||
|
||||
// Filter projects based on current filters
|
||||
const filteredProjects = computed(() => {
|
||||
const searchTerm = search.value.trim().toLowerCase();
|
||||
|
||||
return projects.value.filter((project) => {
|
||||
// Name search
|
||||
if (searchTerm && !project.name.toLowerCase().includes(searchTerm)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (tableState.value.filters.status === 'active' && project.is_archived) {
|
||||
return false;
|
||||
@@ -98,6 +110,15 @@ const filteredProjects = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const hasActiveFilters = computed(() => {
|
||||
return (
|
||||
search.value.trim() !== '' ||
|
||||
tableState.value.filters.status !== 'all' ||
|
||||
tableState.value.filters.visibility !== 'all' ||
|
||||
tableState.value.filters.clientIds.length > 0
|
||||
);
|
||||
});
|
||||
|
||||
// Helper functions for active filters
|
||||
function removeStatusFilter() {
|
||||
tableState.value.filters.status = 'all';
|
||||
@@ -130,8 +151,7 @@ const showBillableRate = computed(() => {
|
||||
|
||||
<template>
|
||||
<AppLayout title="Projects" data-testid="projects_view">
|
||||
<MainContainer
|
||||
class="py-3 sm:pt-5 border-b border-default-background-separator flex justify-between items-center">
|
||||
<MainContainer class="py-3 sm:pt-5 flex justify-between items-center">
|
||||
<div class="flex items-center space-x-3 sm:space-x-6">
|
||||
<PageTitle :icon="FolderIcon" title="Projects"></PageTitle>
|
||||
</div>
|
||||
@@ -184,11 +204,25 @@ const showBillableRate = computed(() => {
|
||||
:clients="clients"
|
||||
@remove="removeClientFilter"
|
||||
@update:value="tableState.filters.clientIds = $event as string[]" />
|
||||
|
||||
<div class="relative">
|
||||
<Search
|
||||
class="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-icon-default" />
|
||||
<TextInput
|
||||
v-model="search"
|
||||
size="sm"
|
||||
type="search"
|
||||
aria-label="Search projects"
|
||||
placeholder="Search projects..."
|
||||
class="w-60 border-transparent bg-transparent pl-7 shadow-none placeholder:text-text-tertiary hover:bg-black/5 focus-visible:bg-input-background dark:hover:bg-white/5 [&::-webkit-search-cancel-button]:hidden" />
|
||||
</div>
|
||||
</div>
|
||||
</MainContainer>
|
||||
|
||||
<ProjectTable
|
||||
:show-billable-rate="showBillableRate"
|
||||
:is-filtered="hasActiveFilters"
|
||||
:is-loading="projectsLoading"
|
||||
:projects="filteredProjects"
|
||||
:sort-column="tableState.sortColumn"
|
||||
:sort-direction="tableState.sortDirection"
|
||||
|
||||
@@ -705,6 +705,7 @@ const UserResource = z
|
||||
profile_photo_url: z.string(),
|
||||
timezone: z.string(),
|
||||
week_start: Weekday,
|
||||
send_time_entry_still_running_email: z.boolean(),
|
||||
})
|
||||
.passthrough();
|
||||
const UserUpdateRequest = z
|
||||
@@ -714,6 +715,7 @@ const UserUpdateRequest = z
|
||||
photo: z.union([z.string(), z.null()]),
|
||||
timezone: z.string(),
|
||||
week_start: Weekday,
|
||||
send_time_entry_still_running_email: z.boolean(),
|
||||
})
|
||||
.partial()
|
||||
.passthrough();
|
||||
|
||||
1
resources/js/types/models.d.ts
vendored
1
resources/js/types/models.d.ts
vendored
@@ -76,6 +76,7 @@ export interface User {
|
||||
two_factor_confirmed_at: string | null;
|
||||
timezone: string;
|
||||
week_start: string;
|
||||
send_time_entry_still_running_email: boolean;
|
||||
profile_photo_url: string;
|
||||
organizations: Organization[];
|
||||
clients: Client[];
|
||||
|
||||
@@ -159,4 +159,26 @@ class TimeEntrySendStillRunningMailsCommandTest extends TestCaseWithDatabase
|
||||
$this->assertSame("Sending still running time entry emails...\n".
|
||||
"Finished sending 0 still running time entry emails...\n", $output);
|
||||
}
|
||||
|
||||
public function test_does_not_send_emails_to_users_who_disabled_them(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = $this->createUserWithPermission();
|
||||
$user->user->send_time_entry_still_running_email = false;
|
||||
$user->user->save();
|
||||
$timeEntry = TimeEntry::factory()->forMember($user->member)->create([
|
||||
'start' => Carbon::now()->subHours(8)->subSecond(),
|
||||
'end' => null,
|
||||
]);
|
||||
|
||||
// Act
|
||||
$exitCode = $this->withoutMockingConsoleOutput()->artisan('time-entry:send-still-running-mails');
|
||||
|
||||
// Assert
|
||||
Mail::assertNothingOutgoing();
|
||||
$this->assertNull($timeEntry->fresh()->still_active_email_sent_at);
|
||||
$this->assertSame(Command::SUCCESS, $exitCode);
|
||||
$this->assertSame("Sending still running time entry emails...\n".
|
||||
"Finished sending 0 still running time entry emails...\n", Artisan::output());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Endpoint\Api\V1;
|
||||
|
||||
use Closure;
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
use Tests\TestCaseWithDatabase;
|
||||
|
||||
@@ -16,4 +19,26 @@ class ApiEndpointTestAbstract extends TestCaseWithDatabase
|
||||
}
|
||||
$response->assertStatus($statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the temporary URL builder of the private disk to capture the options
|
||||
* passed to temporaryUrl. Returns a closure that yields the captured options.
|
||||
*
|
||||
* @return Closure(): (array<string, mixed>|null)
|
||||
*/
|
||||
protected function captureTemporaryUrlOptions(): Closure
|
||||
{
|
||||
$captured = null;
|
||||
Storage::disk(config('filesystems.private'))->buildTemporaryUrlsUsing(
|
||||
function (string $path, DateTimeInterface $expiration, array $options) use (&$captured): string {
|
||||
$captured = $options;
|
||||
|
||||
return 'https://storage.fake/'.$path;
|
||||
}
|
||||
);
|
||||
|
||||
return function () use (&$captured): ?array {
|
||||
return $captured;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,4 +90,35 @@ class ExportEndpointTest extends ApiEndpointTestAbstract
|
||||
$response->assertJsonPath('success', true);
|
||||
$this->assertStringContainsString($filepath, $response->json('download_url'));
|
||||
}
|
||||
|
||||
public function test_export_requests_the_download_url_as_an_attachment(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = $this->createUserWithPermission([
|
||||
'export',
|
||||
]);
|
||||
$filepath = 'exports/export_test.zip';
|
||||
$this->mock(ExportService::class, function (MockInterface $mock) use (&$user, $filepath): void {
|
||||
$mock->shouldReceive('export')
|
||||
->withArgs(function (Organization $organization) use (&$user): bool {
|
||||
return $organization->is($user->organization);
|
||||
})
|
||||
->andReturn($filepath)
|
||||
->once();
|
||||
});
|
||||
Passport::actingAs($user->user);
|
||||
$capturedOptions = $this->captureTemporaryUrlOptions();
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.export.export', [
|
||||
'organization' => $user->organization->getKey(),
|
||||
]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(200);
|
||||
$options = $capturedOptions();
|
||||
$this->assertIsArray($options);
|
||||
$this->assertSame('attachment; filename="export_test.zip"', $options['ResponseContentDisposition'] ?? null);
|
||||
$this->assertSame('https://storage.fake/'.$filepath, $response->json('download_url'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -947,6 +947,35 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
$this->assertResponseCode($response, 200);
|
||||
}
|
||||
|
||||
public function test_index_export_endpoint_requests_the_download_url_as_an_attachment(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:view:all',
|
||||
]);
|
||||
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
|
||||
Passport::actingAs($data->user);
|
||||
$capturedOptions = $this->captureTemporaryUrlOptions();
|
||||
|
||||
// Act
|
||||
$response = $this->getJson(route('api.v1.time-entries.index-export', [
|
||||
$data->organization->getKey(),
|
||||
'format' => ExportFormat::CSV,
|
||||
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
|
||||
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
|
||||
]));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 200);
|
||||
$options = $capturedOptions();
|
||||
$this->assertIsArray($options);
|
||||
$this->assertMatchesRegularExpression(
|
||||
'/^attachment; filename="time-entries-export-.+\.csv"$/',
|
||||
$options['ResponseContentDisposition'] ?? ''
|
||||
);
|
||||
$this->assertStringStartsWith('https://storage.fake/exports/', $response->json('download_url'));
|
||||
}
|
||||
|
||||
public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_ods(): void
|
||||
{
|
||||
// Arrange
|
||||
@@ -1321,6 +1350,38 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
$this->assertResponseCode($response, 200);
|
||||
}
|
||||
|
||||
public function test_aggregate_export_endpoint_requests_the_download_url_as_an_attachment(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:view:all',
|
||||
]);
|
||||
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
|
||||
Passport::actingAs($data->user);
|
||||
$capturedOptions = $this->captureTemporaryUrlOptions();
|
||||
|
||||
// Act
|
||||
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
|
||||
$data->organization->getKey(),
|
||||
'format' => ExportFormat::CSV,
|
||||
'group' => TimeEntryAggregationType::Client,
|
||||
'sub_group' => TimeEntryAggregationType::Project,
|
||||
'history_group' => TimeEntryAggregationTypeInterval::Month,
|
||||
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
|
||||
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
|
||||
]));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 200);
|
||||
$options = $capturedOptions();
|
||||
$this->assertIsArray($options);
|
||||
$this->assertMatchesRegularExpression(
|
||||
'/^attachment; filename="time-entries-report-.+\.csv"$/',
|
||||
$options['ResponseContentDisposition'] ?? ''
|
||||
);
|
||||
$this->assertStringStartsWith('https://storage.fake/exports/', $response->json('download_url'));
|
||||
}
|
||||
|
||||
public function test_aggregate_export_endpoints_can_create_a_csv_report_as_employee_role_with_show_billable_rate(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -45,6 +45,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
|
||||
'profile_photo_url' => $data->user->profile_photo_url,
|
||||
'timezone' => $data->user->timezone,
|
||||
'week_start' => $data->user->week_start->value,
|
||||
'send_time_entry_still_running_email' => true,
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -131,7 +132,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
|
||||
$response->assertJsonValidationErrors('organization_id');
|
||||
}
|
||||
|
||||
public function test_update_changes_user_name_timezone_and_week_start(): void
|
||||
public function test_update_changes_user_profile_settings(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission();
|
||||
@@ -142,6 +143,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
|
||||
'name' => 'Updated Name',
|
||||
'timezone' => 'America/New_York',
|
||||
'week_start' => Weekday::Sunday->value,
|
||||
'send_time_entry_still_running_email' => false,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
@@ -152,6 +154,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
|
||||
'name' => 'Updated Name',
|
||||
'timezone' => 'America/New_York',
|
||||
'week_start' => Weekday::Sunday->value,
|
||||
'send_time_entry_still_running_email' => false,
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -159,6 +162,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
|
||||
$this->assertSame('Updated Name', $user->name);
|
||||
$this->assertSame('America/New_York', $user->timezone);
|
||||
$this->assertSame(Weekday::Sunday, $user->week_start);
|
||||
$this->assertFalse($user->send_time_entry_still_running_email);
|
||||
}
|
||||
|
||||
public function test_update_does_not_change_user_fields_that_are_not_given(): void
|
||||
@@ -503,6 +507,22 @@ class UserEndpointTest extends ApiEndpointTestAbstract
|
||||
$response->assertJsonValidationErrors(['week_start']);
|
||||
}
|
||||
|
||||
public function test_update_fails_if_send_time_entry_still_running_email_is_not_boolean(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), [
|
||||
'send_time_entry_still_running_email' => 'not-a-boolean',
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors(['send_time_entry_still_running_email']);
|
||||
}
|
||||
|
||||
public function test_update_fails_if_photo_is_not_a_string(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
134
tests/Unit/Endpoint/Web/FileDownloadEndpointTest.php
Normal file
134
tests/Unit/Endpoint/Web/FileDownloadEndpointTest.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Endpoint\Web;
|
||||
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Tests the attachment downloads of the private disk via Laravel's storage.{disk} route,
|
||||
* configured with serveUsing/buildTemporaryUrlsUsing in AppServiceProvider::boot.
|
||||
* These tests use the real private disk, because Storage::fake would replace the disk
|
||||
* instance and thereby remove that configuration.
|
||||
*/
|
||||
class FileDownloadEndpointTest extends EndpointTestAbstract
|
||||
{
|
||||
private function privateDisk(): Filesystem
|
||||
{
|
||||
return Storage::disk(config('filesystems.private'));
|
||||
}
|
||||
|
||||
public function test_temporary_url_with_attachment_disposition_serves_file_as_attachment(): void
|
||||
{
|
||||
// Arrange
|
||||
$disk = $this->privateDisk();
|
||||
$disk->put('exports/test-attachment.csv', 'Description,Duration');
|
||||
$url = $disk->temporaryUrl('exports/test-attachment.csv', now()->addMinutes(5), [
|
||||
'ResponseContentDisposition' => 'attachment; filename="test-attachment.csv"',
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = $this->get($url);
|
||||
|
||||
// Assert
|
||||
$response->assertOk();
|
||||
$response->assertDownload('test-attachment.csv');
|
||||
$disk->delete('exports/test-attachment.csv');
|
||||
}
|
||||
|
||||
public function test_temporary_url_without_options_serves_file_inline(): void
|
||||
{
|
||||
// Arrange
|
||||
$disk = $this->privateDisk();
|
||||
$disk->put('exports/test-inline.csv', 'Description,Duration');
|
||||
$url = $disk->temporaryUrl('exports/test-inline.csv', now()->addMinutes(5));
|
||||
|
||||
// Act
|
||||
$response = $this->get($url);
|
||||
|
||||
// Assert
|
||||
$response->assertOk();
|
||||
$this->assertStringStartsWith('inline', (string) $response->headers->get('Content-Disposition'));
|
||||
$disk->delete('exports/test-inline.csv');
|
||||
}
|
||||
|
||||
public function test_download_fails_with_tampered_disposition(): void
|
||||
{
|
||||
// Arrange
|
||||
$url = $this->privateDisk()->temporaryUrl('exports/test-tampered.csv', now()->addMinutes(5), [
|
||||
'ResponseContentDisposition' => 'attachment; filename="test-tampered.csv"',
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = $this->get(str_replace('attachment', 'inline', $url));
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_download_fails_with_expired_signature(): void
|
||||
{
|
||||
// Arrange
|
||||
$url = $this->privateDisk()->temporaryUrl('exports/test-expired.csv', now()->addMinutes(5), [
|
||||
'ResponseContentDisposition' => 'attachment; filename="test-expired.csv"',
|
||||
]);
|
||||
$this->travel(6)->minutes();
|
||||
|
||||
// Act
|
||||
$response = $this->get($url);
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_download_fails_if_file_does_not_exist(): void
|
||||
{
|
||||
// Arrange
|
||||
$url = $this->privateDisk()->temporaryUrl('exports/test-missing.csv', now()->addMinutes(5), [
|
||||
'ResponseContentDisposition' => 'attachment; filename="test-missing.csv"',
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = $this->get($url);
|
||||
|
||||
// Assert
|
||||
$response->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_temporary_url_of_private_local_disk_points_to_storage_route_with_signed_disposition(): void
|
||||
{
|
||||
// Act
|
||||
$url = $this->privateDisk()->temporaryUrl('exports/test-export.pdf', now()->addMinutes(5), [
|
||||
'ResponseContentDisposition' => 'attachment; filename="test-export.pdf"',
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$this->assertStringContainsString('/storage/exports/test-export.pdf', $url);
|
||||
$this->assertStringContainsString('disposition=attachment', $url);
|
||||
$this->assertStringContainsString('signature=', $url);
|
||||
}
|
||||
|
||||
public function test_temporary_url_of_s3_disk_includes_content_disposition_from_options(): void
|
||||
{
|
||||
// Arrange
|
||||
config([
|
||||
'filesystems.disks.s3.key' => 'test-key',
|
||||
'filesystems.disks.s3.secret' => 'test-secret',
|
||||
'filesystems.disks.s3.region' => 'us-east-1',
|
||||
'filesystems.disks.s3.bucket' => 'test-bucket',
|
||||
]);
|
||||
|
||||
// Act
|
||||
$url = Storage::disk('s3')->temporaryUrl('exports/test-export.pdf', now()->addMinutes(5), [
|
||||
'ResponseContentDisposition' => 'attachment; filename="test-export.pdf"',
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$query = [];
|
||||
parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
|
||||
$this->assertSame('attachment; filename="test-export.pdf"', $query['response-content-disposition'] ?? null);
|
||||
$this->assertArrayHasKey('X-Amz-Signature', $query);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user