From 35db442cd3a8e39e492c3213f8241899453364c3 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Wed, 7 Oct 2026 01:48:21 +0200 Subject: [PATCH] feat: activity screen in the SPA, route /activity --- features/activity_screen.feature | 693 ++++++++++++++++++ features/activity_screen.qa.md | 257 +++++++ web/bun.lock | 1 + web/package.json | 1 + web/src/assets/main.css | 13 + web/src/components/ActivityActor.vue | 52 ++ web/src/components/ActivityBadge.vue | 34 + web/src/components/ActivityCardList.vue | 122 +++ web/src/components/ActivityCategorySelect.vue | 44 ++ web/src/components/ActivityChips.vue | 54 ++ web/src/components/ActivityDatePresets.vue | 164 +++++ web/src/components/ActivityDetailsPanel.vue | 224 ++++++ web/src/components/ActivityDiff.vue | 113 +++ web/src/components/ActivityEmptyPanel.vue | 37 + web/src/components/ActivityFilterBar.vue | 174 +++++ web/src/components/ActivityFiltersSheet.vue | 115 +++ web/src/components/ActivityPager.vue | 118 +++ web/src/components/ActivityPersonSelect.vue | 67 ++ web/src/components/ActivitySegmented.vue | 30 + web/src/components/ActivitySentence.vue | 22 + web/src/components/ActivitySummaryStrip.vue | 141 ++++ web/src/components/ActivityTable.vue | 148 ++++ web/src/components/ActivityTargetCell.vue | 34 + web/src/components/DashboardShell.vue | 7 + .../__tests__/DashboardShell.spec.ts | 16 +- web/src/composables/useActivity.ts | 69 ++ web/src/composables/useActivityPage.ts | 316 ++++++++ web/src/composables/useApi.ts | 15 +- web/src/i18n/messages.ts | 179 +++++ web/src/lib/__tests__/activity.spec.ts | 305 ++++++++ .../lib/__tests__/activitySentence.spec.ts | 240 ++++++ web/src/lib/activity.ts | 350 +++++++++ web/src/lib/activitySentence.ts | 209 ++++++ web/src/lib/activitySummary.ts | 109 +++ web/src/lib/activityTime.ts | 110 +++ web/src/lib/segmentStyles.ts | 15 + web/src/router/index.ts | 5 + web/src/views/ActivityView.vue | 296 ++++++++ .../views/__tests__/ActivityDetails.spec.ts | 488 ++++++++++++ .../views/__tests__/ActivityFilters.spec.ts | 509 +++++++++++++ web/src/views/__tests__/ActivityRoles.spec.ts | 238 ++++++ web/src/views/__tests__/ActivityView.spec.ts | 666 +++++++++++++++++ web/src/views/__tests__/MediaView.spec.ts | 1 + web/src/views/__tests__/ProfileView.spec.ts | 4 +- web/src/views/__tests__/activityFixtures.ts | 205 ++++++ web/src/views/__tests__/activityHarness.ts | 206 ++++++ web/visual/states.ts | 377 ++++++++++ 47 files changed, 7582 insertions(+), 11 deletions(-) create mode 100644 features/activity_screen.feature create mode 100644 features/activity_screen.qa.md create mode 100644 web/src/components/ActivityActor.vue create mode 100644 web/src/components/ActivityBadge.vue create mode 100644 web/src/components/ActivityCardList.vue create mode 100644 web/src/components/ActivityCategorySelect.vue create mode 100644 web/src/components/ActivityChips.vue create mode 100644 web/src/components/ActivityDatePresets.vue create mode 100644 web/src/components/ActivityDetailsPanel.vue create mode 100644 web/src/components/ActivityDiff.vue create mode 100644 web/src/components/ActivityEmptyPanel.vue create mode 100644 web/src/components/ActivityFilterBar.vue create mode 100644 web/src/components/ActivityFiltersSheet.vue create mode 100644 web/src/components/ActivityPager.vue create mode 100644 web/src/components/ActivityPersonSelect.vue create mode 100644 web/src/components/ActivitySegmented.vue create mode 100644 web/src/components/ActivitySentence.vue create mode 100644 web/src/components/ActivitySummaryStrip.vue create mode 100644 web/src/components/ActivityTable.vue create mode 100644 web/src/components/ActivityTargetCell.vue create mode 100644 web/src/composables/useActivity.ts create mode 100644 web/src/composables/useActivityPage.ts create mode 100644 web/src/lib/__tests__/activity.spec.ts create mode 100644 web/src/lib/__tests__/activitySentence.spec.ts create mode 100644 web/src/lib/activity.ts create mode 100644 web/src/lib/activitySentence.ts create mode 100644 web/src/lib/activitySummary.ts create mode 100644 web/src/lib/activityTime.ts create mode 100644 web/src/lib/segmentStyles.ts create mode 100644 web/src/views/ActivityView.vue create mode 100644 web/src/views/__tests__/ActivityDetails.spec.ts create mode 100644 web/src/views/__tests__/ActivityFilters.spec.ts create mode 100644 web/src/views/__tests__/ActivityRoles.spec.ts create mode 100644 web/src/views/__tests__/ActivityView.spec.ts create mode 100644 web/src/views/__tests__/activityFixtures.ts create mode 100644 web/src/views/__tests__/activityHarness.ts diff --git a/features/activity_screen.feature b/features/activity_screen.feature new file mode 100644 index 0000000..8d46337 --- /dev/null +++ b/features/activity_screen.feature @@ -0,0 +1,693 @@ +Feature: Activity screen, details slide-over and My activity + + As a verified RenewCMS user + I want /activity to read as a plain-language timeline of who did what and when, with filters, + a summary strip, CSV export and a details panel for each event + So that an owner can audit the site without reading a log, and any other role sees its own history + + Background: + Given /activity is a verified route (meta.auth "verified", meta.title "Activity") rendered as + ActivityView (views/ActivityView.vue, the "ActivityPage" of docs/design/components.md) inside + DashboardShell, per docs/design/routes.md and docs/design/screens/activity.md + And the activity API already exists (features/activity-log-backend.feature and + features/activity-log-producers.feature) and this slice wires the SPA to it unchanged, with + no Go change + And the Home card and the Settings "Retention" card of the same board are not in this slice + And where docs/design prose and the board frame ActivityFrame.dc.html differ, the frame is + followed + And the views read the session role from GET /v1/auth/me: "owner" and "admin" are the full + view; any other or absent value is the regular-user view ("My activity"), which no real + account can reach today and is covered by Vitest and one visual state only + And a full view loads, in parallel, GET /v1/activity?range=7d&page=1&size=25 and GET + /v1/activity/summary?range=7d; a regular-user view loads only GET /v1/activity with the same + query and never calls /summary or /export, and never sends an actor parameter + And the list query always sends range, page and size explicitly, and adds q, actor, category, + outcome, security, from and to only when set (outcome "all" is not sent, from and to only + with range=custom); the summary query sends only range, from and to; the export query sends + the list query without page and size + And filters and the page live in component state, not in the URL, and reset when the user + leaves /activity + And useApi's global handling covers 401 and the unverified 403 + (features/api_client_global_error_handling.feature); this screen maps every other failure + And DashboardShell's navigation gains "Activity" (icon i-lucide-history, linking to /activity) + between "Users" and "Integration", in the sidebar and in the mobile drawer, so it lists Home, + Articles, Media, Users, Activity, Integration, Settings in that order, for every verified + user; "Activity" has aria-current="page" on /activity; the specs that list the destinations + are amended accordingly (named in the QA file) + And the PageHeader breadcrumb and the mobile title read the page title: "Activity", or + "My activity" for the regular-user view + And the page padding is 24 px on desktop and 16 px on mobile with a 16 px gap between the + header, the strip, the filter bar, the chips, the table and the pager + And the header is the title "Activity" (regular user: "My activity") with the subtitle "Who + did what, and when, across your site." (regular user: "Everything you've done on this + site."), and, in the full view except Forbidden, an outline button with icon download and + the label "Export CSV" at the right, aligned to the bottom of the title block + And sentences are composed in the SPA from the event's action code, its outcome, its params + and its target name through the existing i18n (English keys, French in messages.ts), never + stored as text; the target name is user content, is inserted untranslated as a semibold + segment, and is the last slot of every template, so a missing target name drops the slot + with its leading space and colon + And the success sentences are, in English and French (the actor sits in its own column, so + sentences start with the verb; {0} is the target name): + | action | English | French | + | article.create | Created the article {0} | Article créé : {0} | + | article.edit | Edited the article {0} | Article modifié : {0} | + | article.publish | Published the article {0} | Article publié : {0} | + | article.unpublish | Unpublished the article {0} | Article dépublié : {0} | + | article.delete | Deleted the article {0} | Article supprimé : {0} | + | media.upload | Uploaded the image {0} | Image téléversée : {0} | + | media.rename | Renamed the image {0} | Image renommée : {0} | + | media.delete | Deleted the image {0} | Image supprimée : {0} | + | media.merge | Merged duplicates into the image {0} | Doublons fusionnés dans l'image {0} | + | media.purge | Purged duplicate images: {0} merged, {1} articles updated | Doublons purgés : {0} fusionnés, {1} articles mis à jour | + | system.media_purge | Ran the scheduled duplicate purge: {0} merged, {1} articles updated | Purge automatique des doublons : {0} fusionnés, {1} articles mis à jour | + | user.register | Registered the account {0} | Compte créé : {0} | + | user.verify | Verified the account {0} | Compte vérifié : {0} | + | user.update_profile | Updated the profile of {0} | Profil mis à jour : {0} | + | invite.create | Invited {0} | Invitation envoyée à {0} | + | invite.cancel | Cancelled the invite for {0} | Invitation annulée : {0} | + | invite.renew | Renewed the invite for {0} | Invitation renouvelée : {0} | + | invite.accept | Accepted the invite for {0} | Invitation acceptée : {0} | + | api_key.create | Created API key {0} | Clé d'API créée : {0} | + | api_key.revoke | Revoked API key {0} | Clé d'API révoquée : {0} | + | auth.login | Logged in | Connexion | + | auth.logout | Logged out | Déconnexion | + | auth.password_reset_requested | Requested a password reset | Réinitialisation du mot de passe demandée | + | auth.password_reset_completed | Reset the password | Mot de passe réinitialisé | + | system.update_checked | Checked for updates | Recherche de mises à jour | + | system.update_downloaded | Downloaded the update {0} | Mise à jour téléchargée : {0} | + | system.restart_requested (kind restart) | Requested a restart | Redémarrage demandé | + | system.restart_requested (kind update) | Restarted to apply the update {0} | Redémarrage pour appliquer la mise à jour {0} | + | system.activity_retention_changed | Changed the activity retention from {0} to {1} | Durée de conservation de l'activité modifiée : {0} → {1} | + | system.activity_cleanup | Removed {0} old events from the activity log | {0} anciens événements supprimés du journal d'activité | + And the failed sentences, used when outcome is "failed", are: + | action | English | French | + | article.create | Failed to create the article {0} | Échec de la création de l'article {0} | + | article.edit | Failed to edit the article {0} | Échec de la modification de l'article {0} | + | article.publish | Failed to publish the article {0} | Échec de la publication de l'article {0} | + | article.unpublish | Failed to unpublish the article {0} | Échec de la dépublication de l'article {0} | + | article.delete | Failed to delete the article {0} | Échec de la suppression de l'article {0} | + | media.upload | Failed to upload the image {0} | Échec du téléversement de l'image {0} | + | media.rename | Failed to rename the image {0} | Échec du renommage de l'image {0} | + | media.delete | Failed to delete the image {0} | Échec de la suppression de l'image {0} | + | media.merge | Failed to merge duplicates into the image {0} | Échec de la fusion des doublons dans l'image {0} | + | media.purge | Failed to purge duplicate images | Échec de la purge des doublons | + | system.media_purge | Failed to run the scheduled duplicate purge | Échec de la purge automatique des doublons | + | user.register | Failed to register the account {0} | Échec de la création du compte {0} | + | user.verify | Failed to verify the account {0} | Échec de la vérification du compte {0} | + | user.update_profile | Failed to update the profile of {0} | Échec de la mise à jour du profil de {0} | + | invite.create | Failed to invite {0} | Échec de l'invitation de {0} | + | invite.cancel | Failed to cancel the invite for {0} | Échec de l'annulation de l'invitation de {0} | + | invite.renew | Failed to renew the invite for {0} | Échec du renouvellement de l'invitation de {0} | + | invite.accept | Failed to accept the invite for {0} | Échec de l'acceptation de l'invitation de {0} | + | api_key.create | Failed to create API key {0} | Échec de la création de la clé d'API {0} | + | api_key.revoke | Failed to revoke API key {0} | Échec de la révocation de la clé d'API {0} | + | auth.login_failed | Failed login attempt for {0} | Échec de connexion pour {0} | + | system.update_checked | Failed to check for updates | Échec de la recherche de mises à jour | + | system.update_downloaded | Failed to download the update {0} | Échec du téléchargement de la mise à jour {0} | + | system.restart_requested | Failed to restart | Échec du redémarrage | + And an action whose failed sentence is not listed (for example auth.login) renders its success + sentence with the Failed badge; an action code that is not in the catalogue renders the raw + code (for example "article.archive") and never throws + And for auth.login_failed the slot {0} is the actor's name (the attempted username, since the + event has no target), while the Actor column reads "Not signed in" + And counts in params are shown with the locale's number format; "{0} old events" and "{1} + articles updated" use the singular form for 1 ("1 old event", "1 article updated"); a + media.purge or system.media_purge event with params.failed_groups appends ", {2} groups + failed" ("1 group failed") in English and ", {2} groupes en échec" in French; + system.activity_retention_changed shows days as "{n} days" and 0 as "Forever" ("{n} jours", + "Toujours") + And the category icons are: articles file-text, media image, users users, api_keys key-round, + auth shield-check, system server-cog; the category labels are Articles, Media, Users and + invites, API keys, Authentication, System + And the Actor cell is the actor's name with: a UAvatar of initials (first letters of the first + two words, upper case) for type "user"; a key-round icon avatar and an "API key" badge for + "api_key"; a cog icon avatar and a "System" badge for "system"; a user-round-x icon avatar + and the text "Not signed in" for "anonymous" + And the Target cell is "—" (muted) when target is null; when target.exists it is the name as an + underlined link with an arrow-up-right icon, plus the target type as a muted second line + (Article, Image, User, Invite, API key, Release); when it does not exist the name is struck + through, muted, with a "Deleted" badge (icon trash-2) and the type line; the link goes to + /article/{id}/edit for article, /media for image, /users for user and invite, + /integration/api-keys for api_key, and a target type with no page (release) is plain text + And the Outcome cell is a badge with icon and word, never colour alone: "Succeeded" (check, + bordered) or "Failed" (circle-x, --rc-error-soft background, --ui-error text) + And a row whose event has security true shows a shield-alert icon in --ui-warning after the + sentence and a 2 px inset --ui-warning edge on its left side; on mobile it also shows a + "Security" badge (shield-alert, --rc-warning-soft, --ui-warning) + And the table (md and up) has the columns When, Actor, Action, Target, Outcome in a grid + 96px 210px minmax(0,1fr) 230px 120px (regular user: When, Action, Target, Outcome in 96px + minmax(0,1fr) 230px 120px), a 36 px header on --ui-bg-elevated with an arrow-down icon on + When, day separator rows of 32 px on --ui-bg-muted with the label in 12 px semibold, and + rows at least 52 px high with a top border; a selected row (details open) and a hovered row + use --ui-bg-elevated + And rows are grouped by the user's local calendar day under a separator labelled "Today", + "Yesterday" or the date as "3 Oct 2026" (French "3 oct. 2026"), in the order the API + returns them (newest first); the When cell shows the time as drawn, relative within today + ("2 min ago", "29 min ago", "1 h ago") and the local clock time "HH:mm" on earlier days, + and carries the full local time "3 Oct 2026, 14:20:05" as its title attribute + And below md the table is replaced by a stacked list of cards (ActivityCardList) with the same + separators: a first line with the actor (avatar, name, badge; hidden for the regular user) + and the time at the right, the category icon and the sentence (15 px, up to 3 lines), then a + line of badges: the Outcome badge, "Security" when flagged, and "Target deleted" when the + target is gone; each card is at least 44 px high as a touch target + And rows and cards are focusable, open the details on click and on Enter, and a click on the + target link navigates to the target without opening the details + And the summary strip (ActivitySummaryStrip, full view only) has four cards that are real + buttons: "Total actions", "Active people", "Failed actions", "Security events", each with + the number (locale-formatted), a sub-line, and a seven-bar sparkline of seven div bars, + 4 px wide, 2 px apart, oldest at the left, bar height round(value / max(series, 1) * H) + with a 2 px minimum and H of 20 px on desktop and 16 px on mobile, drawn from + series.total, series.active, series.failed and series.security + And on desktop the strip is a 4-column grid with a 12 px gap; below md it is, as drawn in + ActivityFrame.dc.html, a single row of 156 px cards with an 8 px gap that is clipped at the + page edge + And the Total sub-line is "{pct}% vs previous 7 days" with icon trending-up when total is at + least previous_total and trending-down otherwise, pct being the rounded absolute change + against previous_total; for the ranges Today, 30 days and Custom the words read "vs previous + day", "vs previous 30 days" and "vs previous period"; when previous_total is 0 the sub-line + is "No earlier data" and when the two totals are equal it is "No change", both without an + icon (French "Aucune donnée antérieure", "Aucun changement") + And the Active people number is active_people.users plus active_people.api_keys with the + sub-line "{users} people, {api_keys} API keys" ("1 person", "1 API key") + And the Failed sub-line is "{pct}% of all actions" (one decimal, "0.5%") with icon circle-x + and the --rc-error-soft background and --ui-error number + And the Security sub-line is "{n} flagged" (French "{n} signalés", "1 signalé"), n being + summary.security, with icon shield-alert and the --rc-warning-soft background and + --ui-warning number + And a Failed or Security count of 0 reads "0" with "Nothing failed" or "Nothing to flag" and + icon circle-check in --ui-success, drops the emphasis fill and draws its bars in + --ui-border-accented; with total 0 Total and Active people read "0" with "No data yet" + And the strip follows only the date range; q, person, category, outcome and security never + change it, and it is not requested again when they change + And when the summary request fails with any status or never reaches the server, the strip is + not rendered and nothing is shown in its place + And the filter bar (ActivityFilterBar) has: a UInput (icon search, clearable, placeholder + "Search activity", 240 px) that applies 300 ms after the last keystroke or at once on Enter; + a Person USelectMenu (icon user-round, "All people", 168 px, full view only) with the groups + users, API keys and "System"; a Category USelectMenu (icon layout-list, "All categories", + 176 px); an Outcome button group "All / Succeeded / Failed" (soft is the selected one, + outline the others); and the date presets "Today / 7 days / 30 days / Custom" (Custom has + icon calendar and opens a date-range popover) at the right; controls are 32 px high + And the Person options come from GET /v1/user/ (accepted users, shown by display name or + username) and GET /v1/api-key/, requested once, the first time the Person menu opens, so a + page load makes no other call than the two above + And choosing a Person sends actor=user:{id}, actor=key:{id} or actor=system; a Category sends + category=articles|media|users|api_keys|auth|system; Outcome Succeeded sends outcome=success + and Failed outcome=failed; Today, 7 days and 30 days send range=today|7d|30d; Custom sends + range=custom&from=YYYY-MM-DD&to=YYYY-MM-DD once both dates are chosen; any filter change + resets the page to 1 and sends a new list request, and the page size is kept + And the active filter chips (UBadge subtle with a trailing ghost x button) read "Search: + spring", "Person: Marta Alves", "Category: Authentication", "Outcome: Failed", "Security + events", "Dates: 30 days" or "Dates: 1 – 30 Sep 2026" (same month "1 – 30 Sep 2026", across + months "28 Sep – 3 Oct 2026"), followed by a link button "Clear all"; the default range 7 + days is never a chip; removing a chip resets that filter; "Clear all" resets every filter to + the default (range 7d) + And below md the bar is a search field and a "Filters" button (icon sliders-horizontal) with a + count badge (the number of chips, hidden at 0); it opens a bottom sheet titled "Filters" + with Person (full view only), Category, Outcome, Dates (two date fields when Custom is + chosen) and a footer "Clear all" (disabled at 0 filters) and "Show {total} results" ("Show 1 + result"); the sheet applies each change at once, so the footer count is the current total, + and "Show … results" closes the sheet + And the pager (full width under the table) reads "Showing {from}–{to} of {total}" with + locale-formatted numbers, a UPagination (previous, 1, 2, 3, …, last, next; previous + disabled on page 1), and "Rows per page" with a select of 25, 50 and 100; changing the size + resets the page to 1; below md it reads "Page {page} of {pages}" above "{from}–{to} of + {total}", with previous and next buttons and the size select + And while a list request is in flight for longer than 150 ms, 9 skeleton rows (desktop) or 5 + skeleton cards (below md) replace the rows and the pager is hidden; the strip shows 4 + skeleton cards while the summary request is in flight for longer than 150 ms; nothing is + drawn in their place for the first 150 ms + And the list is "empty" when total is 0: with no chip it is "No activity yet" (icon history, + "Actions on articles, media, users and API keys will appear here.", regular user: "Your + actions will appear here once you start working on the site."), with at least one chip it is + "No activity matches these filters" (icon search-x, action "Clear filters") + And the error, empty and forbidden panels are drawn as in ActivityFrame.dc.html: "Couldn't + load activity" is an error alert with icon circle-x, the text and an outline "Try again" + button, and "You can't view this activity" (no trailing period) uses icon lock and the + action "Back to home" + And "Export CSV" is disabled in "No activity yet" and while the list is in the Error state, + and enabled otherwise + And ActivityDetailsPanel is a USlideover: 480 px wide from the right on desktop, an 88 %-high + bottom sheet with a 36×4 px handle below md; its header reads "Activity details" with a + close button (and "{position} of {total}" below md); it opens from a row or card, closes + with Escape, the close button or the scrim, and focus returns to the row that opened it + And the panel body has, in this order: the full sentence (18 px semibold, target in semibold) + with the Outcome badge, a "Security" badge when flagged and the category icon and label; an + error alert "Why it failed" with failure_reason, only when outcome is "failed" and the + reason is present; the grid When, Actor, Source, IP address, Browser; a Target section; + a Changes section + And When shows the local time as "6 Oct 2026, 10:42:18 (your time)" and under it, muted, the + UTC time as "6 Oct 2026, 10:42:18 UTC"; Actor is the avatar, the name and the same badges + as the table; Source is "Web app" for web, "API key" for api_key and "System task" for + system; IP address (IBM Plex Mono 12 px) and Browser read "Not applicable" (muted) when ip + or user_agent is null + And the Target section shows the name (a link with arrow-up-right when it exists, otherwise + struck through with a "Deleted" badge and the line "This item was deleted. Its name is kept + for reference."), and the line "{type} · {id}"; it is absent when target is null + And the Changes section (ActivityDiff) is absent when changes is empty; each change is a row + headed by its field label: a "text" change shows Before and After boxes (side by side with + an arrow-right on desktop, stacked with an arrow-down below md), a "body" change shows only + the line "Body edited (+312 characters)" with icon file-pen-line ("-48", "+1 character") + and never any text + And the field labels are title "Title", online "Publish state", body "Body", filename "File + name", display_name "Display name" (French "Titre", "État de publication", "Corps", "Nom du + fichier", "Nom d'affichage"), an unknown field shows its raw code, and for online the + values "false" and "true" read "Draft" and "Published" ("Brouillon", "Publié") + And a Before or After value taller than two lines is clamped to two lines and a "Show more" + link button appears under that row; it toggles to "Show less"; shorter values show no toggle + And the panel footer has "Previous event" (icon chevron-up) and "Next event" (icon + chevron-down) buttons that move through the current filtered list, and on desktop the + muted text "Event {position} of {total}" with position = (page − 1) × size + index + 1; + below md the two buttons are full width and there is no footer text + And when Previous or Next crosses a page boundary the list page follows, and a failed request + for the neighbour page keeps the panel on the current event with no message + + Scenario: Navigation — "Activity" sits between Users and Integration + Given the user is on /activity + When DashboardShell mounts + Then the sidebar and the mobile drawer list Home, Articles, Media, Users, Activity, + Integration, Settings in that order + And "Activity" has the history icon, links to /activity and has aria-current="page" + And the breadcrumb reads "Activity" (mobile title "Activity") + + Scenario: Default — an owner sees the strip, the filter bar and a day-grouped table + Given GET /v1/activity resolves 200 with 25 items and total 1284, and GET /v1/activity/summary + resolves 200 with total 1284, previous_total 1146, active_people {users 7, api_keys 2}, + failed 6, security 11 and seven-day series, and the session role is "owner" + When the user navigates to /activity + Then the header shows "Activity", "Who did what, and when, across your site." and an enabled + "Export CSV" + And the strip reads "1,284" "12% vs previous 7 days" with trending-up; "9" "7 people, 2 API + keys"; "6" "0.5% of all actions"; "11" "11 flagged" + And the filter bar shows "Search activity", "All people", "All categories", All selected in + the Outcome group, and 7 days selected, with no chip + And the table rows follow the API order under day separators + And the pager reads "Showing 1–25 of 1,284" and "Rows per page" 25 + And GET /v1/activity was requested once with range=7d, page=1, size=25 and no other + parameter, and GET /v1/activity/summary once with range=7d + + Scenario Outline: Rows — the Actor cell and the sentence follow the event + Given a row for an event with actor type "" named "", action "", outcome + "" and security + Then the Actor cell shows + And the Action cell shows "" with the target name in semibold, and + And the Outcome cell shows "" + + Examples: + | type | name | action | outcome | security | actor | sentence | marker | badge | + | user | Marta Alves | article.publish | success | false | an avatar "MA" and "Marta Alves" | Published the article Spring release | no shield | Succeeded | + | api_key | Website sync | media.upload | success | false | a key icon, "Website sync", "API key" | Uploaded the image north-wall.jpg | no shield | Succeeded | + | system | System | system.media_purge | success | false | a cog icon, "System", "System" | Ran the scheduled duplicate purge: 3 merged, 5 articles updated | no shield | Succeeded | + | anonymous | alex@example.com | auth.login_failed | failed | true | a user-round-x icon, "Not signed in" | Failed login attempt for alex@example.com | a shield-alert marker | Failed | + | user | Inès Duarte | article.publish | failed | false | an avatar "ID" and "Inès Duarte" | Failed to publish the article Spring release | no shield | Failed | + | user | Jonas Weber | api_key.revoke | success | true | an avatar "JW" and "Jonas Weber" | Revoked API key Mobile app | a shield-alert marker | Succeeded | + + Scenario: Rows — a deleted target is struck through, a living one is a link + Given an event of target {"type": "image", "id": "9", "name": "old-banner.png", "exists": false} + and an event of target {"type": "article", "id": "8", "name": "Spring release", "exists": true} + Then the first Target cell shows "old-banner.png" struck through with a "Deleted" badge and + "Image" + And the second Target cell shows "Spring release" as a link to /article/8/edit and "Article" + And clicking that link navigates to /article/8/edit and does not open the details + + Scenario: Rows — an event with no target shows a dash + Given an event whose target is null + Then its Target cell shows a muted "—" + + Scenario: Rows — an unknown action code renders the code + Given an event with action "article.archive" + Then its Action cell shows "article.archive" and the row still opens the details + + Scenario Outline: Sentences — English and French come from the same code and params + Given an event with action "", outcome "", params and target + "" + When the language is + Then the sentence reads "" with "" untranslated + + Examples: + | action | outcome | params | target | language | sentence | + | article.publish | success | {} | Spring release | English | Published the article Spring release | + | article.publish | success | {} | Spring release | French | Article publié : Spring release | + | article.publish | failed | {} | Spring release | French | Échec de la publication de l'article Spring release | + | media.purge | success | {"merged": 3, "articles_updated": 5} | | English | Purged duplicate images: 3 merged, 5 articles updated | + | media.purge | success | {"merged": 3, "articles_updated": 5, "failed_groups": 1} | | English | Purged duplicate images: 3 merged, 5 articles updated, 1 group failed | + | system.activity_cleanup | success | {"removed": 1} | | English | Removed 1 old event from the activity log | + | system.activity_retention_changed | success | {"from": 90, "to": 0} | | English | Changed the activity retention from 90 days to Forever | + | system.restart_requested | success | {"kind": "update", "tag": "v1.4.0"} | v1.4.0 | English | Restarted to apply the update v1.4.0 | + | media.upload | failed | {} | | English | Failed to upload the image | + + Scenario: Loading — skeletons after 150 ms while both requests are in flight + Given GET /v1/activity and GET /v1/activity/summary have not resolved + When the user navigates to /activity + Then the header, the filter bar and an enabled "Export CSV" are drawn at once + And nothing is drawn in place of the strip and the table for the first 150 ms + And after 150 ms 4 skeleton cards replace the strip and 9 skeleton rows (5 cards below md) + replace the table, the pager is hidden and the table container is aria-busy="true" + When both requests resolve 200 with the Default data + Then the skeletons are replaced by the Default state's strip, rows and pager + + Scenario: Zero-value summary strip — a quiet period shows a calm 0 + Given GET /v1/activity/summary resolves 200 with total 214, previous_total 223, active_people + {users 4, api_keys 1}, failed 0, security 0 and series of zeros for failed and security + And GET /v1/activity resolves 200 with 25 items + When the user navigates to /activity + Then the Total card reads "214" "4% vs previous 7 days" with trending-down + And the Active people card reads "5" "4 people, 1 API key" + And the Failed card reads "0" "Nothing failed" with circle-check and no error fill + And the Security card reads "0" "Nothing to flag" with circle-check and no warning fill + And their bars are 2 px high + + Scenario Outline: The Total sub-line when there is nothing to compare + Given GET /v1/activity/summary resolves 200 with total and previous_total + When the user navigates to /activity + Then the Total card reads "" and "" without a trend icon + + Examples: + | total | previous | subline | + | 40 | 0 | No earlier data | + | 40 | 40 | No change | + + Scenario: Empty, no activity yet — a new site + Given GET /v1/activity resolves 200 with items [] and total 0, and GET /v1/activity/summary + resolves 200 with every number 0 + And no filter chip is shown + When the user navigates to /activity + Then the strip reads "0" "No data yet" for Total and Active people, "0" "Nothing failed" and + "0" "Nothing to flag" + And an empty panel with icon history shows "No activity yet" and "Actions on articles, + media, users and API keys will appear here." + And "Export CSV" is disabled and no table header or pager is shown + + Scenario: Empty, filters match nothing — the panel offers Clear filters + Given the Default state has loaded + When the user types "sitemap" in Search, chooses Category "Media" and Outcome "Failed", and + GET /v1/activity resolves 200 with items [] and total 0 + Then the chips read "Search: sitemap", "Category: Media", "Outcome: Failed" and "Clear all" + And an empty panel with icon search-x shows "No activity matches these filters" and "Try a + different search or a wider date range." with the action "Clear filters" + And "Export CSV" stays enabled and the strip is unchanged + When the user activates "Clear filters" + Then every filter is reset, GET /v1/activity is sent with range=7d, page=1, size=25 only and + the rows come back + + Scenario Outline: Error with retry — a 500, a 400 or a network failure replaces the table + Given GET /v1/activity and GET /v1/activity/summary resolves 200 + When the user navigates to /activity + Then the strip and the filter bar are shown + And an error alert shows "Couldn't load activity" and "Check your connection and try again." + with the action "Try again" + And "Export CSV" is disabled and no table or pager is shown + When the user activates "Try again" + Then the Loading state shows for the list and GET /v1/activity is sent again with the same + query + When it then resolves 200 with the Default data + Then the Default rows replace the alert + + Examples: + | failure | + | resolves with a 500 response | + | resolves with a 400 response | + | never reaches the server | + + Scenario Outline: The summary alone fails — the strip is not drawn and the list still works + Given GET /v1/activity resolves 200 with the Default items and GET /v1/activity/summary + + When the user navigates to /activity + Then the strip is not rendered, with no error shown for it + And the filter bar, the rows and the pager are shown as in Default + + Examples: + | failure | + | resolves with a 500 response | + | resolves with a 403 response | + | never reaches the server | + + Scenario: Forbidden (403) — the page itself is refused + Given GET /v1/activity resolves 403 and GET /v1/auth/me resolves 200 with a verified user + When the user navigates to /activity + Then the header shows "Activity" and no "Export CSV" + And no strip, filter bar, chips, table or pager is shown + And an empty panel with icon lock shows "You can't view this activity" and "Your role + doesn't include this page. Ask an owner or admin if you need access." with the action "Back + to home" + When the user activates "Back to home" + Then the router navigates to /home + + Scenario: An unverified 403 and a 401 are handled globally + Given GET /v1/activity resolves 401 (or 403 for an unverified session) + When the user navigates to /activity + Then useApi redirects to /login?redirect=/activity&reason=expired (or to /register/pending) + And no Forbidden or Error panel is shown + + Scenario Outline: Strip cards — each card applies its filter + Given the Default state has loaded with the 7 days range + When the user activates the "" card + Then + And + + Examples: + | card | effect | state | + | Total actions | every filter is cleared and GET /v1/activity is sent with range=7d | no card is marked active | + | Active people | nothing changes and no request is sent | no card is marked active | + | Failed actions | GET /v1/activity is sent with outcome=failed and the page 1 | the chip "Outcome: Failed" and "Clear all" appear and the card has aria-pressed="true" with a --ui-primary border | + | Security events | GET /v1/activity is sent with security=true and the page 1 | the chip "Security events" and "Clear all" appear and the card has aria-pressed="true" with a --ui-primary border | + + Scenario: Filtered, card applied — the Failed card on top of other filters + Given the user has chosen Category "Authentication" and 30 days + When the user activates the "Failed actions" card and GET /v1/activity resolves 200 with 1 + item and total 6 + Then the chips read "Category: Authentication", "Outcome: Failed", "Dates: 30 days" and + "Clear all" + And GET /v1/activity/summary was requested for range=30d and is not requested again + And the pager reads "Showing 1–6 of 6" + And below md the "Filters" button shows the count badge "3" + + Scenario Outline: Filter bar — each control sends its query parameter and resets the page + Given the user is on page 3 of the Default list + When + Then GET /v1/activity is sent with , page=1 and size=25 + + Examples: + | action | query | + | the user types "spring" and waits 300 ms | q=spring and range=7d | + | the user types "spring" and presses Enter at once | q=spring and range=7d | + | the user chooses Person "Marta Alves" (user 3) | actor=user:3 and range=7d | + | the user chooses Person "Website sync" (API key 7) | actor=key:7 and range=7d | + | the user chooses Person "System" | actor=system and range=7d | + | the user chooses Category "Users and invites" | category=users and range=7d | + | the user chooses Outcome "Succeeded" | outcome=success and range=7d | + | the user chooses "Today" | range=today | + | the user chooses "30 days" | range=30d | + + Scenario: Filter bar — Custom opens a date-range popover and applies on the second date + Given the Default state has loaded + When the user chooses "Custom" and picks 1 Sep 2026 then 30 Sep 2026 in the popover + Then GET /v1/activity and GET /v1/activity/summary are sent with range=custom, from=2026-09-01 + and to=2026-09-30 + And the chip reads "Dates: 1 – 30 Sep 2026" + And no request is sent after the first date alone + + Scenario: Chips — removing one resets only that filter, Clear all resets everything + Given the chips "Search: spring", "Outcome: Failed" and "Dates: 30 days" are shown + When the user activates the x of "Outcome: Failed" + Then GET /v1/activity is sent with q=spring and range=30d and without outcome + When the user activates "Clear all" + Then GET /v1/activity is sent with range=7d only, and the summary is requested for range=7d + And no chip and no "Clear all" is shown + + Scenario: The Person menu loads its options once, on first open + Given the Default state has loaded + Then GET /v1/user/ and GET /v1/api-key/ have not been requested + When the user opens the Person menu + Then both are requested once and the menu lists the users, then the API keys, then "System" + And opening it again does not request them again + + Scenario: Pagination — next page, page size and totals + Given the Default state has loaded with total 1284 + When the user activates page "2" of the pager + Then GET /v1/activity is sent with page=2 and size=25 and the pager reads "Showing 26–50 of + 1,284" + When the user chooses "50" in "Rows per page" + Then GET /v1/activity is sent with page=1 and size=50 and the pager reads "Showing 1–50 of + 1,284" + And on the last page 52 of size 25 the pager reads "Showing 1,276–1,284 of 1,284" and next is + disabled + + Scenario: Mobile — the list is cards and the controls collapse into a sheet + Given the Default state has loaded below md with the filters Category "Authentication" and + Outcome "Failed" applied and GET /v1/activity resolves with total 6 + Then the page shows the search field and "Filters" with the badge "2", and no Person, + Category, Outcome or date control + And the table is replaced by cards, each with its actor, time, sentence and badges + And the pager reads "Page 1 of 1" with "1–6 of 6" + When the user activates "Filters" + Then a bottom sheet titled "Filters" shows Person, Category, Outcome, Dates, "Clear all" and + "Show 6 results" + When the user chooses "Custom" + Then two date fields appear in the sheet + When the user activates "Show 6 results" + Then the sheet closes and the list stays filtered + + Scenario: Export — the file is prepared, then downloads + Given the Default state has loaded with the filters Category "Authentication" and 30 days + When the user activates "Export CSV" + Then GET /v1/activity/export is sent with category=auth and range=30d and without page or size + And while it is in flight the button reads "Preparing file…" and is disabled + When it resolves 200 with a text/csv body + Then the file is downloaded as "activity.csv" and a toast appears: color success, icon + i-lucide-circle-check, title "Export started. Your file will download shortly." + And the button returns to "Export CSV", enabled + + Scenario Outline: Export — a failure raises an error toast and re-enables the button + Given the Default state has loaded + When the user activates "Export CSV" and GET /v1/activity/export + Then a toast appears: color error, icon i-lucide-circle-x, title "Couldn't export activity. + Try again." + And nothing is downloaded and the button returns to "Export CSV", enabled + + Examples: + | failure | + | resolves with a 500 response | + | resolves with a 403 response | + | never reaches the server | + + Scenario: Details — a row opens the slide-over and focus returns on close + Given the Default state has loaded + When the user activates the second row (click, or Enter with the row focused) + Then the slide-over opens with the title "Activity details", the row is marked selected and + the footer reads "Event 2 of 1,284" + When the user presses Escape (or activates the close button, or the scrim) + Then the slide-over closes and focus is on that row + + Scenario: Details — the Edit with a diff event + Given the event "Why we reuse original floorboards" has action "article.edit" with changes + [{"field": "title", "kind": "text", "before": "Why we reuse floorboards", "after": "Why we + reuse original floorboards"}, {"field": "body", "kind": "body", "delta": 312}] + When its details open + Then the sentence reads "Edited the article Why we reuse original floorboards" + And the Changes section shows a "Title" row with Before "Why we reuse floorboards" and After + "Why we reuse original floorboards", then a "Body" row with "Body edited (+312 characters)" + And no body text appears anywhere in the panel + + Scenario: Details — long values clamp to two lines with Show more + Given an event with a text change whose Before and After are 170-character values, and + another text change with short values + When its details open and the long values overflow two lines + Then the long row's values are clamped and "Show more" appears under that row only + When the user activates "Show more" + Then the values are fully shown and the link reads "Show less" + + Scenario: Details — a failed action with a very long target title shows its reason + Given an event with action "article.publish", outcome "failed", failure_reason "The article + has no cover image. Add one, then publish again." and a 150-character target name + When its details open + Then the sentence wraps with the title in semibold, the "Failed" badge is shown and an error + alert "Why it failed" reads the reason + And the Target section wraps the long name + + Scenario: Details — a deleted target has no diff + Given an event of action "media.delete" with target {"type": "image", "id": "9", "name": + "old-banner.png", "exists": false} and no changes + When its details open + Then the Target section shows "old-banner.png" struck through, a "Deleted" badge and "This + item was deleted. Its name is kept for reference." + And there is no Changes section + + Scenario: Details — a system-initiated event has no human actor + Given an event with actor {"type": "system", "id": null, "name": "System"}, source "system", + ip null and user_agent null + When its details open + Then Actor shows the cog avatar, "System" and a "System" badge, Source reads "System task", + and IP address and Browser read "Not applicable" + + Scenario: Details — a failed login shows the actor as not signed in + Given an event with action "auth.login_failed", actor {"type": "anonymous", "id": null, + "name": "alex@example.com"}, security true, outcome "failed", failure_reason "Wrong username + or password.", ip "198.51.100.77" and user_agent "Chrome 129 on Windows" + When its details open + Then the sentence reads "Failed login attempt for alex@example.com" with a "Security" badge + and a "Failed" badge + And Actor reads "Not signed in", the alert "Why it failed" shows the reason, and the IP + address and the Browser are shown + + Scenario: Details — the times are shown in local time and in UTC + Given an event created_at "2026-10-06T10:42:18Z" + When its details open + Then the When line ends with "(your time)" and the muted line below reads "6 Oct 2026, + 10:42:18 UTC" + + Scenario Outline: Details — Source reads the event's source + Given an event with source "" + When its details open + Then Source reads "