Merge pull request #1667 from projectsend/feature/file-activity-tab-and-download-filters

Activity tab on a file's page, filters on both history screens
This commit is contained in:
Ignacio Nelson
2026-08-21 13:33:44 -03:00
committed by GitHub
9 changed files with 667 additions and 31 deletions
@@ -5,12 +5,17 @@ declare(strict_types=1);
namespace App\Modules\Audit\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Audit\ActivityLogScope;
use App\Modules\Audit\DownloadPresenter;
use App\Modules\Files\Models\File;
use App\Modules\Platform\Localization\LocalDay;
use App\Modules\Platform\Localization\TimezoneRegistry;
use App\Support\Pagination;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
@@ -27,6 +32,7 @@ class DownloadsController extends Controller
public function __construct(
private readonly DownloadPresenter $presenter,
private readonly ActivityLogScope $scope,
private readonly TimezoneRegistry $timezones,
) {}
public function index(Request $request): Response
@@ -34,15 +40,9 @@ class DownloadsController extends Controller
$viewer = $request->user();
assert($viewer !== null);
// A download row names the file and says who fetched it from which
// IP, so it needs the viewer's library scope applied — not just
// `view_actions_log`. See ActivityLogScope for the full reasoning.
$entries = $this->scope
->apply(ActivityLog::query(), $viewer)
->where('subject_type', (new File)->getMorphClass())
->whereIn('action', [Action::FileDownloaded, Action::ShareLinkDownloaded, Action::PublicFileDownloaded])
->orderByDesc('created_at')
->orderByDesc('id')
$filters = $this->validatedFilters($request);
$entries = $this->filteredQuery($filters, $viewer)
->paginate(25)
->withQueryString();
@@ -63,6 +63,65 @@ class DownloadsController extends Controller
];
})->all(),
'pagination' => Pagination::meta($entries),
'filters' => $filters,
]);
}
/**
* @return array{file: ?string, user: ?string, from: ?string, to: ?string}
*/
private function validatedFilters(Request $request): array
{
$validated = $request->validate([
'file' => ['nullable', 'string', 'max:255'],
'user' => ['nullable', 'string', 'max:255'],
'from' => ['nullable', 'date'],
'to' => ['nullable', 'date', 'after_or_equal:from'],
]);
return [
'file' => $validated['file'] ?? null,
'user' => $validated['user'] ?? null,
'from' => $validated['from'] ?? null,
'to' => $validated['to'] ?? null,
];
}
/**
* @param array{file: ?string, user: ?string, from: ?string, to: ?string} $filters
* @return Builder<ActivityLog>
*/
private function filteredQuery(array $filters, User $viewer): Builder
{
$timezone = $this->timezones->resolve($viewer);
// A download row names the file and says who fetched it from which
// IP, so it needs the viewer's library scope applied — not just
// `view_actions_log`. See ActivityLogScope for the full reasoning.
return $this->scope
->apply(ActivityLog::query(), $viewer)
->where('subject_type', (new File)->getMorphClass())
->whereIn('action', [Action::FileDownloaded, Action::ShareLinkDownloaded, Action::PublicFileDownloaded])
// Both names are matched on what the entry snapshotted, not on
// a join: a file or an account deleted since is still findable
// by the name it went out under, which is often exactly what
// this page is being asked.
->when($filters['file'], fn (Builder $query, string $file) => $query->where('subject_name', 'like', "%{$file}%"))
// Only rows with a real account can match a name. The two
// anonymous flavours ("Public link", "Public listing") are
// labels this page prints, not stored values, so a search for
// them finds nothing rather than something arbitrary.
->when($filters['user'], fn (Builder $query, string $user) => $query->where('actor_name', 'like', "%{$user}%"))
// The viewer's own calendar day, not the UTC one — see LocalDay.
->when(
$filters['from'] !== null ? LocalDay::start($filters['from'], $timezone) : null,
fn (Builder $query, Carbon $from) => $query->where('created_at', '>=', $from),
)
->when(
$filters['to'] !== null ? LocalDay::end($filters['to'], $timezone) : null,
fn (Builder $query, Carbon $to) => $query->where('created_at', '<=', $to),
)
->orderByDesc('created_at')
->orderByDesc('id');
}
}
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Modules\Files\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Audit\ActivityPresenter;
@@ -18,10 +19,15 @@ use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Models\ShareLink;
use App\Modules\Files\Versions\FileVersionLinks;
use App\Modules\Platform\Localization\LocalDay;
use App\Modules\Platform\Localization\TimezoneRegistry;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
@@ -34,6 +40,29 @@ class FileDetailsController extends Controller
/** Raw rows considered when grouping downloads() by actor — see that method's docblock. */
private const DOWNLOADS_SUMMARY_LIMIT = 500;
/**
* Filters that stand for a question rather than for one logged action.
*
* "Who downloaded this file?" is not one action: a signed-in recipient,
* somebody following a public link and a visitor to a public group
* listing are recorded separately, on purpose, because *how* a file
* left matters. But nobody reading a file's history wants to ask the
* question three times, so this offers it once and only when the
* file's own log holds more than one of the members, since otherwise
* it would filter to exactly what its single member already offers.
*
* Previewing has one action today, so it needs no group; give it one
* here if a second way to preview a file is ever recorded separately.
*
* @var array<string, array{label: string, actions: non-empty-list<Action>}>
*/
private const ACTION_GROUPS = [
'downloads' => [
'label' => 'All downloads',
'actions' => [Action::FileDownloaded, Action::ShareLinkDownloaded, Action::PublicFileDownloaded],
],
];
public function __construct(
private readonly ActivityPresenter $presenter,
private readonly DownloadPresenter $downloadPresenter,
@@ -41,6 +70,7 @@ class FileDetailsController extends Controller
private readonly CommentingRules $commenting,
private readonly FileVersionLinks $versionLinks,
private readonly DownloadAllowance $allowance,
private readonly TimezoneRegistry $timezones,
) {}
public function show(Request $request, File $file): JsonResponse
@@ -148,7 +178,15 @@ class FileDetailsController extends Controller
Gate::forUser($viewer)->authorize('view', $file);
abort_unless($viewer->can('view_actions_log'), 403);
return $this->renderHistory($file->getMorphClass(), $file->id, $file->name, route('files.edit', $file, false));
return $this->renderHistory(
$request,
$file->getMorphClass(),
$file->id,
$file->name,
route('files.edit', $file, false).'?tab=activity',
'files.activity.history',
['file' => $file->id],
);
}
/**
@@ -304,15 +342,35 @@ class FileDetailsController extends Controller
Gate::forUser($viewer)->authorize('view', $folder);
abort_unless($viewer->can('view_actions_log'), 403);
return $this->renderHistory($folder->getMorphClass(), $folder->id, $folder->name, route('files.index', ['folder' => $folder->id], false));
return $this->renderHistory(
$request,
$folder->getMorphClass(),
$folder->id,
$folder->name,
route('files.index', ['folder' => $folder->id], false),
'folders.activity.history',
['folder' => $folder->id],
);
}
private function renderHistory(string $morphClass, int $subjectId, string $subjectName, string $backUrl): Response
{
$entries = ActivityLog::query()
->where('subject_type', $morphClass)
->where('subject_id', $subjectId)
->orderByDesc('created_at')->orderByDesc('id')
/**
* @param array<string, mixed> $routeParams
*/
private function renderHistory(
Request $request,
string $morphClass,
int $subjectId,
string $subjectName,
string $backUrl,
string $routeName,
array $routeParams,
): Response {
$viewer = $request->user();
assert($viewer !== null);
$filters = $this->validatedHistoryFilters($request);
$entries = $this->historyQuery($morphClass, $subjectId, $filters, $viewer)
->paginate(25)
->withQueryString();
@@ -327,8 +385,129 @@ class FileDetailsController extends Controller
'next' => $entries->nextPageUrl(),
'total' => $entries->total(),
],
'filters' => $filters,
'action_options' => $this->actionOptions($morphClass, $subjectId),
'subject_name' => $subjectName,
'back_url' => $backUrl,
'route_name' => $routeName,
'route_params' => $routeParams,
]);
}
/**
* The actions this subject's history actually contains, with how many
* times each happened.
*
* Built from the log rather than from `Action::cases()`: the enum has
* over eighty members and all but a handful can never appear against a
* file, so offering them all would be a dropdown you scroll past the
* answer in. What is here is what happened.
*
* @return list<array{key: string, label: string, count: int}>
*/
private function actionOptions(string $morphClass, int $subjectId): array
{
/** @var array<string, int> $counts */
$counts = ActivityLog::query()
->where('subject_type', $morphClass)
->where('subject_id', $subjectId)
->selectRaw('action, count(*) as total')
->groupBy('action')
->pluck('total', 'action')
->map(fn ($total): int => (int) $total)
->all();
$options = [];
foreach (self::ACTION_GROUPS as $key => $group) {
$present = array_filter($group['actions'], fn (Action $action): bool => isset($counts[$action->value]));
// One member present means the group would filter to exactly
// what its member already offers, under a vaguer name.
if (count($present) < 2) {
continue;
}
$options[] = [
'key' => $key,
'label' => $group['label'],
'count' => array_sum(array_map(fn (Action $action): int => $counts[$action->value], $present)),
];
}
// Enum order, not count order, so the list does not rearrange
// itself under the reader every time the file is downloaded.
foreach (Action::cases() as $action) {
if (! isset($counts[$action->value])) {
continue;
}
$options[] = [
'key' => $action->value,
'label' => $action->description(),
'count' => $counts[$action->value],
];
}
return $options;
}
/**
* @return array{action: ?string, actor: ?string, from: ?string, to: ?string}
*/
private function validatedHistoryFilters(Request $request): array
{
$validated = $request->validate([
'action' => ['nullable', Rule::in([
...array_keys(self::ACTION_GROUPS),
...array_column(Action::cases(), 'value'),
])],
'actor' => ['nullable', 'string', 'max:255'],
'from' => ['nullable', 'date'],
'to' => ['nullable', 'date', 'after_or_equal:from'],
]);
return [
'action' => $validated['action'] ?? null,
'actor' => $validated['actor'] ?? null,
'from' => $validated['from'] ?? null,
'to' => $validated['to'] ?? null,
];
}
/**
* @param array{action: ?string, actor: ?string, from: ?string, to: ?string} $filters
* @return Builder<ActivityLog>
*/
private function historyQuery(string $morphClass, int $subjectId, array $filters, User $viewer): Builder
{
$timezone = $this->timezones->resolve($viewer);
return ActivityLog::query()
->where('subject_type', $morphClass)
->where('subject_id', $subjectId)
->when($filters['action'], function (Builder $query, string $action): void {
$group = self::ACTION_GROUPS[$action] ?? null;
$group === null
? $query->where('action', $action)
: $query->whereIn('action', array_map(fn (Action $member): string => $member->value, $group['actions']));
})
// Matched on the name snapshotted onto the entry, the same as
// the main log: an account deleted since is still findable by
// the name it acted under, which is the whole point of the
// snapshot.
->when($filters['actor'], fn (Builder $query, string $actor) => $query->where('actor_name', 'like', "%{$actor}%"))
// The reader's own calendar day, not the UTC one — see LocalDay.
->when(
$filters['from'] !== null ? LocalDay::start($filters['from'], $timezone) : null,
fn (Builder $query, Carbon $from) => $query->where('created_at', '>=', $from),
)
->when(
$filters['to'] !== null ? LocalDay::end($filters['to'], $timezone) : null,
fn (Builder $query, Carbon $to) => $query->where('created_at', '<=', $to),
)
->orderByDesc('created_at')
->orderByDesc('id');
}
}
@@ -206,6 +206,11 @@ class FilesController extends Controller
'can_update' => Gate::forUser($viewer)->allows('update', $file),
'can_delete' => Gate::forUser($viewer)->allows('delete', $file),
'can_manage_public' => $viewer->can('upload_public'),
// Whether this page offers its Activity tab. The file's own
// page is where somebody lands from a link, a search or a
// notification, so "what happened to this file" has to be
// answerable here and not only from the library's list.
'can_view_activity' => $viewer->can('view_actions_log'),
// The per-file switch only does anything while the comment
// scope is `selected`; under every other value the page hides
// it rather than offer a control with no current effect.
+65 -3
View File
@@ -3,10 +3,13 @@ import { Head, Link } from '@inertiajs/react';
import { ArrowLeft } from 'lucide-react';
import Heading from '@/components/heading';
import { FilterField, ListToolbar } from '@/components/list-toolbar';
import { Pagination, PaginationMeta } from '@/components/pagination';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useFormatDate } from '@/hooks/use-format-date';
import { useListQuery } from '@/hooks/use-list-query';
import { useTranslation } from '@/hooks/use-translation';
import AppLayout from '@/layouts/app-layout';
@@ -20,9 +23,19 @@ interface DownloadEntry {
file_url?: string | null;
}
interface Filters {
file: string | null;
user: string | null;
from: string | null;
to: string | null;
}
interface ActivityDownloadsProps {
entries: DownloadEntry[];
pagination: PaginationMeta;
// Only the installation-wide page filters: a single file's history is
// already narrowed to the one thing its filters would ask about.
filters?: Filters;
// Present only when scoped to a single file/folder (the details
// panel's "View all downloads" destination). Absent for the
// installation-wide /downloads page, which shows a File column
@@ -31,12 +44,23 @@ interface ActivityDownloadsProps {
back_url?: string;
}
export default function ActivityDownloads({ entries, pagination, subject_name, back_url }: ActivityDownloadsProps) {
export default function ActivityDownloads({ entries, pagination, filters, subject_name, back_url }: ActivityDownloadsProps) {
const { t } = useTranslation();
const { dateTime } = useFormatDate();
const scoped = subject_name !== undefined && back_url !== undefined;
const { values, set, reset, hasFilters } = useListQuery(
'downloads.index',
{
file: filters?.file ?? '',
user: filters?.user ?? '',
from: filters?.from ?? '',
to: filters?.to ?? '',
},
{ file: '', user: '', from: '', to: '' },
);
const breadcrumbs: BreadcrumbItem[] = scoped
? [
{ title: t('Activity log'), href: '/activity' },
@@ -45,7 +69,11 @@ export default function ActivityDownloads({ entries, pagination, subject_name, b
: [{ title: t('Download history'), href: '/downloads' }];
const title = scoped ? t('Download history for :name', { name: subject_name }) : t('Download history');
const description = scoped ? t('Every download, newest first') : t('Every download across the installation, newest first');
const description = scoped
? t('Every download, newest first')
: hasFilters
? t(':count matching this filter, newest first', { count: pagination.total })
: t('Every download across the installation, newest first');
return (
<AppLayout breadcrumbs={breadcrumbs}>
@@ -64,6 +92,40 @@ export default function ActivityDownloads({ entries, pagination, subject_name, b
)}
</div>
{filters !== undefined && (
<ListToolbar showClear={hasFilters} onClear={reset}>
<FilterField label={t('File')} htmlFor="filter-file">
<Input
id="filter-file"
type="search"
placeholder={t('Search by name')}
className="w-56"
value={values.file}
onChange={(e) => set('file', e.target.value, true)}
/>
</FilterField>
<FilterField label={t('Downloaded by')} htmlFor="filter-user">
<Input
id="filter-user"
type="search"
placeholder={t('Search by name')}
className="w-48"
value={values.user}
onChange={(e) => set('user', e.target.value, true)}
/>
</FilterField>
<FilterField label={t('From')} htmlFor="filter-from">
<Input id="filter-from" type="date" className="w-40" value={values.from} onChange={(e) => set('from', e.target.value)} />
</FilterField>
<FilterField label={t('To')} htmlFor="filter-to">
<Input id="filter-to" type="date" className="w-40" value={values.to} onChange={(e) => set('to', e.target.value)} />
</FilterField>
</ListToolbar>
)}
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead>
@@ -78,7 +140,7 @@ export default function ActivityDownloads({ entries, pagination, subject_name, b
{entries.length === 0 && (
<tr>
<td colSpan={scoped ? 3 : 4} className="text-muted-foreground px-4 py-8 text-center">
{t('No downloads recorded yet.')}
{hasFilters ? t('No downloads match these filters.') : t('No downloads recorded yet.')}
</td>
</tr>
)}
+87 -3
View File
@@ -3,11 +3,15 @@ import { Head, Link } from '@inertiajs/react';
import { ArrowLeft } from 'lucide-react';
import Heading from '@/components/heading';
import { FilterField, ListToolbar } from '@/components/list-toolbar';
import { Pagination, PaginationMeta } from '@/components/pagination';
import { TableShell } from '@/components/table-shell';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useFormatDate } from '@/hooks/use-format-date';
import { ALL, useListQuery } from '@/hooks/use-list-query';
import { useTranslation } from '@/hooks/use-translation';
import AppLayout from '@/layouts/app-layout';
import { activityActorLabel as actorLabel } from '@/lib/activity-actor';
@@ -23,17 +27,51 @@ interface ActivityEntry {
replacements: Record<string, string>;
}
interface Filters {
action: string | null;
actor: string | null;
from: string | null;
to: string | null;
}
interface ActivitySubjectProps {
entries: ActivityEntry[];
pagination: PaginationMeta;
filters: Filters;
/** Only the actions this subject's own history contains, with their counts. */
action_options: { key: string; label: string; count: number }[];
subject_name: string;
back_url: string;
/** This same page, for the filter links — a file's history and a folder's are different routes. */
route_name: string;
route_params: Record<string, unknown>;
}
export default function ActivitySubject({ entries, pagination, subject_name, back_url }: ActivitySubjectProps) {
export default function ActivitySubject({
entries,
pagination,
filters,
action_options,
subject_name,
back_url,
route_name,
route_params,
}: ActivitySubjectProps) {
const { t } = useTranslation();
const { dateTime } = useFormatDate();
const { values, set, reset, hasFilters } = useListQuery(
route_name,
{
action: filters.action ?? ALL,
actor: filters.actor ?? '',
from: filters.from ?? '',
to: filters.to ?? '',
},
{ action: ALL, actor: '', from: '', to: '' },
route_params,
);
const breadcrumbs: BreadcrumbItem[] = [
{ title: t('Activity log'), href: '/activity' },
{ title: subject_name, href: back_url },
@@ -45,7 +83,16 @@ export default function ActivitySubject({ entries, pagination, subject_name, bac
<div className="px-4 py-6">
<div className="flex items-start justify-between gap-4">
<Heading title={t('Activity history for :name', { name: subject_name })} description={t('Every recorded action, newest first')} />
{/* Once a filter is on, "every recorded action" is no
longer what the table shows — say how many it does. */}
<Heading
title={t('Activity history for :name', { name: subject_name })}
description={
hasFilters
? t(':count matching this filter, newest first', { count: pagination.total })
: t('Every recorded action, newest first')
}
/>
<Button variant="outline" asChild>
<Link href={back_url}>
<ArrowLeft className="size-4" />
@@ -54,10 +101,47 @@ export default function ActivitySubject({ entries, pagination, subject_name, bac
</Button>
</div>
<ListToolbar showClear={hasFilters} onClear={reset}>
<FilterField label={t('Action')} htmlFor="filter-action">
<Select value={values.action} onValueChange={(v) => set('action', v)}>
<SelectTrigger id="filter-action" className="w-72">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>{t('All actions')}</SelectItem>
{action_options.map((option) => (
<SelectItem key={option.key} value={option.key}>
{t(':action (:count)', { action: t(option.label), count: option.count })}
</SelectItem>
))}
</SelectContent>
</Select>
</FilterField>
<FilterField label={t('Account')} htmlFor="filter-actor">
<Input
id="filter-actor"
type="search"
placeholder={t('Search by name')}
className="w-48"
value={values.actor}
onChange={(e) => set('actor', e.target.value, true)}
/>
</FilterField>
<FilterField label={t('From')} htmlFor="filter-from">
<Input id="filter-from" type="date" className="w-40" value={values.from} onChange={(e) => set('from', e.target.value)} />
</FilterField>
<FilterField label={t('To')} htmlFor="filter-to">
<Input id="filter-to" type="date" className="w-40" value={values.to} onChange={(e) => set('to', e.target.value)} />
</FilterField>
</ListToolbar>
<TableShell
columns={[t('Date'), t('Account'), t('Action')]}
isEmpty={entries.length === 0}
emptyMessage={<>{t('No activity recorded yet.')}</>}
emptyMessage={<>{hasFilters ? t('No activity matches these filters.') : t('No activity recorded yet.')}</>}
>
{entries.map((entry) => (
<tr key={entry.id} className="border-b last:border-0">
+84 -8
View File
@@ -1,7 +1,7 @@
import { type BreadcrumbItem } from '@/types';
import { Head, router, useForm, usePage } from '@inertiajs/react';
import { Check, Copy, Download, X } from 'lucide-react';
import { FormEventHandler, useState } from 'react';
import { Check, Copy, Download, Loader2, X } from 'lucide-react';
import { FormEventHandler, useEffect, useState } from 'react';
import { CommentThread } from '@/components/comments/comment-thread';
import { ConfirmDialog } from '@/components/confirm-dialog';
@@ -22,6 +22,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { useFormatDate } from '@/hooks/use-format-date';
import { useTranslation } from '@/hooks/use-translation';
import AppLayout from '@/layouts/app-layout';
import { activityActorLabel } from '@/lib/activity-actor';
import { categoryColor } from '@/lib/category-colors';
import { formatBytes } from '@/lib/format-bytes';
import { isThumbnailable } from '@/lib/thumbnails';
@@ -47,7 +48,19 @@ interface CategoryTag {
color: string;
}
type Tab = 'general' | 'sharing' | 'links' | 'versions' | 'comments';
type Tab = 'general' | 'sharing' | 'links' | 'versions' | 'comments' | 'activity';
/** One line of this file's history, as /files/{id}/activity presents it. */
interface ActivityEntry {
id: number;
created_at: string;
actor_name: string | null;
actor_type: string | null;
/** Separates an unauthenticated visitor from the scheduler; both have no actor. */
origin: string;
template: string;
replacements: Record<string, string>;
}
interface FilesEditProps {
file: {
@@ -84,6 +97,7 @@ interface FilesEditProps {
can_update: boolean;
can_delete: boolean;
can_manage_public: boolean;
can_view_activity: boolean;
can_set_commentable: boolean;
comments_enabled: boolean;
assigned_clients: Named[];
@@ -109,6 +123,7 @@ export default function FilesEdit({
can_update,
can_delete,
can_manage_public,
can_view_activity,
can_set_commentable,
comments_enabled,
assigned_clients,
@@ -121,7 +136,7 @@ export default function FilesEdit({
can_limit_downloads,
}: FilesEditProps) {
const { t } = useTranslation();
const { date } = useFormatDate();
const { date, dateTime } = useFormatDate();
const pageErrors = usePage().props.errors as Record<string, string>;
// A comment notification and the activity log both land here, so honour
// ?tab=comments. Somebody who may read the file but not edit it gets the
@@ -130,8 +145,13 @@ export default function FilesEdit({
const requested = new URLSearchParams(window.location.search).get('tab');
if (requested === 'comments' && comments_enabled) return 'comments';
if (requested === 'activity' && can_view_activity) return 'activity';
return can_update ? 'general' : 'comments';
if (can_update) return 'general';
// Whatever is left: somebody who may read the file but not edit it
// has at most these two, and Comments is the one they came for.
return comments_enabled ? 'comments' : 'activity';
});
const [target, setTarget] = useState('');
const [shareExpiresAt, setShareExpiresAt] = useState('');
@@ -145,6 +165,25 @@ export default function FilesEdit({
// (an already-populated slug counts as touched so we never clobber a
// deliberate value).
const [slugTouched, setSlugTouched] = useState(file.slug !== '');
// The file's own history, fetched from the same endpoint the library's
// details panel reads — the most recent 20 entries plus how many there
// are in total, so the link below can say what "all" amounts to. Null
// until the tab has been opened: a page that nobody opens the tab on
// should not pay for the query.
const [activity, setActivity] = useState<ActivityEntry[] | null>(null);
const [activityTotal, setActivityTotal] = useState(0);
useEffect(() => {
if (tab !== 'activity' || activity !== null || !can_view_activity) return;
fetch(route('files.activity', file.id), { headers: { Accept: 'application/json' }, credentials: 'same-origin' })
.then((r) => r.json())
.then((d) => {
setActivity(d.entries);
setActivityTotal(d.total);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab]);
const breadcrumbs: BreadcrumbItem[] = [
{ title: t('All files'), href: '/files' },
@@ -278,12 +317,13 @@ export default function FilesEdit({
</div>
</div>
{(can_update || comments_enabled) && (
{(can_update || comments_enabled || can_view_activity) && (
<nav className="mt-6 flex gap-1 border-b">
{[
...(can_update ? (['general', 'sharing', 'links'] as Tab[]) : []),
...(can_set_version ? (['versions'] as Tab[]) : []),
...(comments_enabled ? (['comments'] as Tab[]) : []),
...(can_view_activity ? (['activity'] as Tab[]) : []),
]
.filter((tabKey) => tabKey !== 'links' || can_manage_public)
.map((tabKey) => (
@@ -300,14 +340,50 @@ export default function FilesEdit({
? t('Public')
: tabKey === 'versions'
? t('Versions')
: t('Comments')}
: tabKey === 'comments'
? t('Comments')
: t('Activity')}
</button>
))}
</nav>
)}
<div className="mt-6">
{tab === 'comments' ? (
{tab === 'activity' ? (
<div className="max-w-2xl">
{activity === null ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" /> {t('Loading…')}
</div>
) : activity.length === 0 ? (
<p className="text-muted-foreground text-sm">{t('No activity recorded yet.')}</p>
) : (
<div className="divide-y rounded-md border">
{activity.map((entry) => (
<div key={entry.id} className="flex items-baseline justify-between gap-4 px-4 py-3 text-sm">
<p>
<span className="font-medium">{t(activityActorLabel(entry).key)}</span>{' '}
<span className="text-muted-foreground">{t(entry.template, entry.replacements)}</span>
</p>
<p className="text-muted-foreground shrink-0 text-xs">{dateTime(entry.created_at)}</p>
</div>
))}
</div>
)}
{/* Offered whenever there is any history at all, the
same as the library's details panel: the full
page is filterable and paged, not merely longer. */}
{activityTotal > 0 && (
<div className="mt-3">
<Button variant="link" size="sm" className="px-0" asChild>
<a href={route('files.activity.history', file.id)}>
{t('View full history (:count)', { count: activityTotal })}
</a>
</Button>
</div>
)}
</div>
) : tab === 'comments' ? (
// The same thread the library's slide-over renders —
// one component, so the two can never disagree about
// what a comment looks like or who may write one.
+66
View File
@@ -178,3 +178,69 @@ test('a client-scoped viewer does see entries for their own clients and their ow
// permission alone, so it never points at a 403.
->and($rows->firstWhere('file_name', 'quarterly-report')['file_url'])->not->toBeNull();
});
test('the installation-wide download history filters by file, by account and by date', function () {
$report = uploadImageFile($this->admin, 'quarterly-report.jpg');
$photo = uploadImageFile($this->admin, 'holiday-photo.jpg');
$client = User::factory()->client()->create(['name' => 'Acme Design']);
$log = function ($file, ?User $actor, string $when) {
ActivityLog::create([
'action' => $actor === null ? Action::ShareLinkDownloaded : Action::FileDownloaded,
'subject_type' => $file->getMorphClass(),
'subject_id' => $file->id,
'subject_name' => $file->name,
'actor_id' => $actor?->id,
'actor_name' => $actor?->name,
'actor_type' => $actor === null ? null : 'client',
'created_at' => $when,
]);
};
$log($report, $client, '2026-08-10 09:00:00');
$log($report, $this->admin, '2026-08-12 09:00:00');
$log($photo, $client, '2026-08-14 09:00:00');
// Anonymous: no account name to match, so the user filter can never
// return it — which is what the column already says on screen.
$log($photo, null, '2026-08-16 09:00:00');
$entries = fn (string $query) => $this->actingAs($this->admin)->get("/downloads{$query}")->assertOk();
$entries('?file=report')->assertInertia(
fn (AssertableInertia $page) => $page->has('entries', 2)->where('filters.file', 'report'),
);
$entries('?user=Acme')->assertInertia(
fn (AssertableInertia $page) => $page->has('entries', 2)->where('entries.0.actor_name', 'Acme Design'),
);
// Both filters at once narrow to the one row that satisfies each.
$entries('?file=report&user=Acme')->assertInertia(fn (AssertableInertia $page) => $page->has('entries', 1));
$entries('?from=2026-08-13')->assertInertia(fn (AssertableInertia $page) => $page->has('entries', 2));
$entries('?to=2026-08-11')->assertInertia(fn (AssertableInertia $page) => $page->has('entries', 1));
$entries('?from=2026-08-11&to=2026-08-15')->assertInertia(fn (AssertableInertia $page) => $page->has('entries', 2));
// A range that ends before it starts is rejected rather than silently
// returning nothing.
$this->actingAs($this->admin)->get('/downloads?from=2026-08-15&to=2026-08-11')->assertSessionHasErrors('to');
});
test('a client-scoped viewer cannot widen the download history with a filter', function () {
$secret = uploadImageFile($this->admin, 'board-minutes-confidential.jpg');
$this->actingAs($this->admin)->get("/files/{$secret->id}/download")->assertOk();
$manager = User::factory()->role(SystemRole::ClientManager)->create();
$manager->assignedClients()->sync([]);
// Out of this viewer's library scope, so it stays invisible however
// precisely it is searched for: the filters narrow the scoped query,
// they do not replace it.
$this->actingAs($manager)->get('/downloads?file=board-minutes')->assertInertia(
fn (AssertableInertia $page) => $page->has('entries', 0),
);
$this->actingAs($this->admin)->get('/downloads?file=board-minutes')->assertInertia(
fn (AssertableInertia $page) => $page->has('entries', 1),
);
});
+25
View File
@@ -15,6 +15,7 @@ use App\Modules\Identity\Models\RolePermission;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Http\UploadedFile;
use Inertia\Testing\AssertableInertia;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
@@ -236,3 +237,27 @@ test('a file with no limit says so rather than reporting a zero', function () {
->assertJsonPath('expires_at', null)
->assertJsonPath('expired', false);
});
test("the file's own page carries an activity tab, behind the same permission", function () {
$this->actingAs($this->admin)->post('/files', [
'file' => UploadedFile::fake()->create('a.pdf', 10, 'application/pdf'), 'name' => '', 'description' => '',
]);
$file = File::query()->sole();
$this->actingAs($this->admin)->get("/files/{$file->id}")->assertOk()->assertInertia(
fn (AssertableInertia $page) => $page->component('files/edit')->where('can_view_activity', true),
);
// The uploader of *this* file, but with no view_actions_log: the page
// still loads, without the tab that would show the log.
$role = Role::query()->create(['name' => 'No Log']);
foreach (['upload', 'edit_own_files'] as $permission) {
RolePermission::query()->create(['role_id' => $role->id, 'permission' => $permission]);
}
$noLog = User::factory()->create(['role_id' => $role->id]);
$file->update(['uploaded_by' => $noLog->id]);
$this->actingAs($noLog)->get("/files/{$file->id}")->assertOk()->assertInertia(
fn (AssertableInertia $page) => $page->component('files/edit')->where('can_view_activity', false),
);
});
@@ -197,3 +197,83 @@ test('the details panel downloads summary is bounded but reports the true total'
->and($response->json('downloaders'))->toHaveCount(1)
->and($response->json('downloaders.0.count'))->toBe(500);
});
test('the file activity history filters by action, and offers only the actions that happened', function () {
$file = uploadImageFile($this->admin);
$client = User::factory()->client()->create(['name' => 'Downloading Client']);
$log = function (Action $action, ?User $actor = null) use ($file): void {
ActivityLog::create([
'action' => $action,
'subject_type' => $file->getMorphClass(),
'subject_id' => $file->id,
'subject_name' => $file->name,
'actor_id' => $actor?->id,
'actor_name' => $actor?->name,
'actor_type' => $actor === null ? null : 'client',
'created_at' => now(),
]);
};
$log(Action::FileDownloaded, $client);
$log(Action::FileDownloaded, $client);
$log(Action::ShareLinkDownloaded);
$log(Action::FilePreviewed, $client);
$options = $this->actingAs($this->admin)->get("/files/{$file->id}/activity/history")
->assertInertia(fn (AssertableInertia $page) => $page->component('activity/subject'))
->viewData('page')['props']['action_options'];
$byKey = collect($options)->keyBy('key');
// The upload happened; the eighty-odd actions that cannot apply to a
// file are not offered at all.
expect($byKey->keys()->all())->toEqualCanonicalizing(['downloads', 'file.uploaded', 'file.downloaded', 'file.previewed', 'share_link.downloaded'])
->and($byKey['downloads']['count'])->toBe(3)
->and($byKey['file.downloaded']['count'])->toBe(2);
// A file whose log holds only one flavour of download gets no group:
// it would filter to exactly what its single member already offers.
$simple = uploadImageFile($this->admin, 'simple.jpg');
ActivityLog::create([
'action' => Action::FileDownloaded,
'subject_type' => $simple->getMorphClass(),
'subject_id' => $simple->id,
'subject_name' => $simple->name,
'actor_id' => $this->admin->id,
'actor_name' => $this->admin->name,
'actor_type' => 'staff',
'created_at' => now(),
]);
$simpleOptions = $this->actingAs($this->admin)->get("/files/{$simple->id}/activity/history")
->viewData('page')['props']['action_options'];
expect(collect($simpleOptions)->pluck('key')->all())->toEqualCanonicalizing(['file.uploaded', 'file.downloaded']);
// The group covers all three download flavours, one of them anonymous.
$this->actingAs($this->admin)->get("/files/{$file->id}/activity/history?action=downloads")->assertInertia(
fn (AssertableInertia $page) => $page->has('entries', 3)->where('filters.action', 'downloads'),
);
$this->actingAs($this->admin)->get("/files/{$file->id}/activity/history?action=file.previewed")->assertInertia(
fn (AssertableInertia $page) => $page->has('entries', 1)->where('entries.0.actor_name', 'Downloading Client'),
);
// Narrowing by who acted, and by day, works the same as the main log.
$this->actingAs($this->admin)->get("/files/{$file->id}/activity/history?actor=Downloading")->assertInertia(
fn (AssertableInertia $page) => $page->has('entries', 3),
);
$this->actingAs($this->admin)->get("/files/{$file->id}/activity/history?from=".now()->addDay()->toDateString())->assertInertia(
fn (AssertableInertia $page) => $page->has('entries', 0),
);
// An action that exists but never touched this file filters to nothing
// rather than erroring; a value that is neither action nor group is
// rejected outright.
$this->actingAs($this->admin)->get("/files/{$file->id}/activity/history?action=user.created")->assertInertia(
fn (AssertableInertia $page) => $page->has('entries', 0),
);
$this->actingAs($this->admin)->get("/files/{$file->id}/activity/history?action=nonsense")->assertSessionHasErrors('action');
});