Compare commits

...
3 Commits
76 changed files with 10219 additions and 57 deletions

No files matched your search

+2 -2
View File
@@ -320,8 +320,8 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
ListActivityUseCase: useCases.NewListActivityUseCase(repos.AuditRepo, repos.UserRepo, repos.ArticleRepo, repos.ImageRepo, repos.APIKeyRepo, time.Now), ListActivityUseCase: useCases.NewListActivityUseCase(repos.AuditRepo, repos.UserRepo, repos.ArticleRepo, repos.ImageRepo, repos.APIKeyRepo, time.Now),
GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(repos.AuditRepo, time.Now), GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(repos.AuditRepo, time.Now),
ExportActivityUseCase: useCases.NewExportActivityUseCase(repos.AuditRepo, time.Now), ExportActivityUseCase: useCases.NewExportActivityUseCase(repos.AuditRepo, time.Now),
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(repos.AuditSettingsRepo), GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(repos.AuditSettingsRepo, time.Now),
UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(repos.AuditSettingsRepo, recorder), UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(repos.AuditSettingsRepo, recorder, time.Now),
} }
} }
+1 -1
View File
@@ -40,7 +40,7 @@ type filterQuery struct {
type listQuery struct { type listQuery struct {
filterQuery filterQuery
Page int `validate:"gte=1"` Page int `validate:"gte=1"`
Size int `validate:"oneof=25 50 100"` Size int `validate:"oneof=5 25 50 100"`
} }
type actorResponse struct { type actorResponse struct {
+2
View File
@@ -26,6 +26,7 @@ type settingsResponse struct {
ActivityRetentionDays int `json:"activity_retention_days"` ActivityRetentionDays int `json:"activity_retention_days"`
ActivityLastCleanupAt *time.Time `json:"activity_last_cleanup_at"` ActivityLastCleanupAt *time.Time `json:"activity_last_cleanup_at"`
ActivityLastCleanupRemoved int `json:"activity_last_cleanup_removed"` ActivityLastCleanupRemoved int `json:"activity_last_cleanup_removed"`
ActivityNextCleanupAt *time.Time `json:"activity_next_cleanup_at"`
} }
func NewHandler(getSettings *useCases.GetSettingsUseCase, getRetention *useCases.GetActivityRetentionUseCase, updateRetention *useCases.UpdateActivityRetentionUseCase) *Handler { func NewHandler(getSettings *useCases.GetSettingsUseCase, getRetention *useCases.GetActivityRetentionUseCase, updateRetention *useCases.UpdateActivityRetentionUseCase) *Handler {
@@ -70,6 +71,7 @@ func (h *Handler) write(w http.ResponseWriter, retention audit.Settings) {
ActivityRetentionDays: retention.RetentionDays, ActivityRetentionDays: retention.RetentionDays,
ActivityLastCleanupAt: retention.LastCleanupAt, ActivityLastCleanupAt: retention.LastCleanupAt,
ActivityLastCleanupRemoved: retention.LastCleanupRemoved, ActivityLastCleanupRemoved: retention.LastCleanupRemoved,
ActivityNextCleanupAt: retention.NextCleanupAt,
}) })
if err != nil { if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
+47
View File
@@ -0,0 +1,47 @@
This release adds an activity log that records who did what on your site, and lets RenewCMS run on MySQL, MariaDB or PostgreSQL and keep uploaded images in an S3-compatible bucket.
## New
**Activity log**
- The new Activity page lists every important action as a plain sentence, newest first, grouped by day: articles created, edited, published or deleted, images uploaded, renamed, merged or purged, accounts and invitations, and the system tasks (scheduled purge, update checks and downloads, restarts).
- Filter by search text, person, category, outcome and date range, with Today, 7 days, 30 days and a custom range. Click a summary card to apply its filter. Open any row to see its details, including what changed.
- Failed actions are recorded with a plain reason, and failed logins and similar events are flagged as security events.
- Owners and admins see everyone's activity, a summary strip (total, active people, failed actions, security events) and can export the filtered list as a CSV file. Any other role would only see its own activity.
- Article bodies are never recorded, only the change in size. Passwords, codes, file contents and storage paths are never recorded either.
- The Home page has a new Recent activity card with the last five events.
- Owners choose how long events are kept in the new Retention card in Settings: 30 days, 90 days (the default), 365 days or forever. Shortening the period asks for confirmation first. Older events are removed automatically once a day, and Settings shows the last cleanup and the next one.
- The new screens are available in English and French.
**More ways to run RenewCMS**
- Use MySQL 8, MariaDB 11 or PostgreSQL 16 and 17 instead of the local SQLite file by setting `DB_DRIVER` to `mysql` or `postgres` and the `DB_*` connection variables. SQLite stays the default.
- Keep uploaded images in an S3-compatible bucket (AWS S3, MinIO, Ceph, Cloudflare R2, Backblaze B2 and similar) by setting `STORAGE_DRIVER=s3`. Image URLs do not change and the bucket stays private.
- The update backup taken before a download now works with every database: SQLite as before, and a logical dump for MySQL, MariaDB and PostgreSQL. The newest 3 backups are kept.
- Docker Compose files for each setup are included: `docker-compose.mysql.yml`, `docker-compose.postgres.yml` and `docker-compose.s3.yml`.
## API
- `GET /v1/activity` returns the filtered, paged events as `{ items, total, page, size }`, newest first. It accepts `q`, `actor`, `category`, `outcome`, `security`, `range`, `from`, `to`, `page` and `size`, and any invalid value answers `400`. Owners and admins see every event. Any other role only sees its own, and asking for another actor answers `403`.
- `GET /v1/activity/summary` returns the counts for the range, the previous period, the active people and a seven-bucket series. Owners and admins only.
- `GET /v1/activity/export` returns the same filters as a CSV attachment (`when,actor,action,target,outcome,source,ip`). Owners and admins only. Cells that start with `=`, `+`, `-` or `@` get a leading quote.
- The three routes accept a session only: an API key answers `403` with "This action needs a real session."
- There is no `POST`, `PUT`, `PATCH` or `DELETE` under `/v1/activity`. Events can only be removed by the retention period.
- `GET /v1/settings` now also returns `activity_retention_days`, `activity_last_cleanup_at`, `activity_last_cleanup_removed` and `activity_next_cleanup_at`.
- `PATCH /v1/settings` accepts `activity_retention_days` with `0` (forever), `30`, `90` or `365`. Session and owner only, with `403` "Only the owner can do this." for anyone else. Any other value answers `400`.
## Under the hood
- Decouple persistence and image storage behind driver registries: `persistence/database` for the SQL drivers and `internal/infrastructure/storage` for the image adapters, with the domain `image.Storage` port. Repositories no longer touch files.
- Add the audit domain, an append-only repository, a recorder that never fails or blocks the caller, and the use cases that record events from inside each action.
- Add a scheduler for the activity cleanup, which runs once a day at or after 03:00 server time, alongside the media purge scheduler.
- Add the activity, Home card and Retention card specifications and QA procedures to `features/`, the new screens to `docs/design/`, and Go and Vitest suites for each part.
- Document the new variables in `docs/configuration.md`, and the new setups in `docs/deployment.md` and `docs/docker.md`.
## Upgrading
- Replace the binary, or pull the new image. The activity tables are created automatically at startup.
- New environment variables are all optional and default to the previous behaviour: `DB_DRIVER` (`sqlite`), `STORAGE_DRIVER` (`local`), `DATA_DIR` (the directory of `DB_FILE`), and the `DB_*` and `S3_*` variables listed in `docs/configuration.md`. An existing install needs no change.
- Activity starts recording after the upgrade, and nothing earlier is added. The first cleanup applies the default retention of 90 days, so owners who want a longer history should change it in Settings.
- There is no automatic migration between databases or from the upload directory to a bucket. To move, follow the steps in `docs/configuration.md` and `docs/deployment.md`.
- Backups taken before an update cover the database only. With S3, back the bucket up with your provider.
+1
View File
@@ -273,6 +273,7 @@ Feature: The back-office keeps an append-only activity log that owners can read
| range=30d | events of the last 30 days | | range=30d | events of the last 30 days |
| range=custom&from=2026-09-01&to=2026-09-30 | events from 1 to 30 September 2026 UTC | | range=custom&from=2026-09-01&to=2026-09-30 | events from 1 to 30 September 2026 UTC |
| page=2&size=50 | the second page of 50 | | page=2&size=50 | the second page of 50 |
| size=5 | the first page of 5 |
Scenario: Omitting range means 7 days Scenario: Omitting range means 7 days
Given events 2 days old and 10 days old Given events 2 days old and 10 days old
@@ -0,0 +1,349 @@
Feature: Recent activity card on Home and Retention card on Settings
As a RenewCMS user
I want Home to show my last five activity events and the owner to choose how long activity is kept on Settings
So that I see what happened without opening the Activity page, and the log does not grow without bound
Background:
Given the activity API (features/activity-log-backend.feature, activity-log-producers.feature, activity_screen.feature) and the settings retention fields already exist
And this slice adds one field to the settings API, activity_next_cleanup_at, and wires the SPA to the existing endpoints; there is no other Go change
And the Home card and the Settings "Retention" card are the two items activity_screen.feature left out of scope; the spec of record is docs/design/screens/home.md ("Recent activity card") and docs/design/screens/settings.md ("Activity retention"), groups "Home card" and "Settings retention" of RenewCMS Screens Activity.dc.html
And both boards draw the existing cards as dashed placeholders, so neither card is pixel-compared and Vitest is the SPA coverage
And "owner" and "admin" are the full Home view; any other or absent session role is the regular-user view
And every new English string has a French entry in messages.ts
And GET /v1/settings (every verified user) and PATCH /v1/settings (owner only, behind RequireOwner) both answer with the JSON object {"version", "activity_retention_days", "activity_last_cleanup_at", "activity_last_cleanup_removed", "activity_next_cleanup_at"}, snake_case, nothing else
And this supersedes the statement of features/settings_page.feature that the body is exactly {"version": "<value>"}; the version field and the verified-user access of that feature are unchanged
And activity_next_cleanup_at is an RFC 3339 timestamp with the server's offset, or null when activity_retention_days is 0 (Forever)
And otherwise it is the next 03:00 (audit.CleanupHour) in the server's time zone strictly after the server's current time
And the value is computed in the domain (audit.Settings) from the clock the use cases are built with, never in the handler and never in the SPA
And the cleanup it announces is the existing daily job, which never touches the last 24 hours (audit.KeepRecentHours)
And GetActivityRetentionUseCase and UpdateActivityRetentionUseCase both return the value, so a PATCH response carries the next cleanup of the new period (null after switching to Forever, a timestamp after leaving Forever)
And the existing validation, 400 with the text of apperr.BodyErrorMessage for any value outside 0, 30, 90, 365 or a malformed body, and 403 for a non-owner are unchanged
And the domain package stays free of GORM, HTTP and the clock source (the clock is passed in)
And on /home a card "Recent activity" (RecentActivityCard) sits in a 420 px column to the right of the existing cards from the lg breakpoint up, with a 16 px gap, in a grid (minmax(0,1fr) 420px, items aligned to the start) that spans the full content width, with no max-width, as the board frame draws it
And the greeting header and the update banner stay above the grid at full width
And below lg the card is stacked above the stat tiles and the recent article list, below the banner, with a 16 px gap
And the card is --ui-bg with a 1 px --ui-border and a header row (12px 16px padding, 1 px bottom border) holding the title (14 px semibold, --ui-text-highlighted) and, at the right, the link "View all" with an arrow-right icon (14 px; 44 px touch height below lg) to /activity
And the title is "Recent activity", and "Your recent activity" in the regular-user view
And on mount the card sends exactly one GET /v1/activity?size=5 with no other parameter and never an actor parameter; it never calls /summary or /export; the server returns own events only for a regular user
And no range is sent, so the server's default of 7 days applies and a site quiet for more than a week reads "No activity yet"
And the card loads independently of GET /v1/article/: neither waits for or blocks the other, and an error or empty card leaves the stat tiles, the recent article list, the articles' own empty and error states and "Connect your website" as they are
And each row (up to 5, in API order) has a 24 px avatar, the sentence clamped to two lines with the target name in semibold (composed by ActivitySentence exactly as on /activity, never stored text), then a muted 12 px line: "{actor name} · {time}" in the full view and only "{time}" in the regular-user view
And the avatar is the same as the Actor cell of /activity (initials for a user, key-round for an API key, cog for the system, user-round-x for an anonymous actor)
And the time of an event of today is "{n} min ago" or "{n} h ago" (lib/activityTime), an event of yesterday reads "Yesterday, 14:20" and an older one "3 Oct 2026, 14:20" (French "Hier, 14:20", "3 oct. 2026, 14:20"); the full local time with seconds is its title attribute
And a failed event shows a "Failed" badge at the right of its row; a successful event with security true shows a small shield icon (14 px, --ui-warning) and no badge; a failed security event shows the badge only
And rows are not interactive; only "View all" navigates
And while the request is in flight for longer than 150 ms the card body shows five skeleton rows (24 px circle, two bars) and aria-busy="true"; nothing is drawn in the body for the first 150 ms
And on a 500, any other non-401 status or a network failure the body is an inline role="alert" error with icon circle-x, title "Couldn't load recent activity", no description, and a "Try again" button that sends the same GET /v1/activity?size=5 again; a 401 is handled globally by useApi
And when total is 0 the body is the empty state: icon history, "No activity yet" and "Actions on your site will appear here." (regular user: "Your actions will appear here.")
And on /settings, for the owner only, a third card (RetentionCard) follows UpdatesCard and MediaCleanupCard and has the same width as its sibling cards; for an admin, a regular user or an absent role it is not rendered at all
And the card reads GET /v1/settings, the single call SettingsView already makes, whose typed response is widened with the four activity_* fields; the page still sends exactly one GET /v1/settings per navigation
And the header is "Retention" (14 px semibold) over "How long activity is kept." (12 px muted) with a status area at the right, aria-live="polite": "Saving…" (loader-circle) while a PATCH is in flight, "Saved" (check, --ui-success) for 2 s after it succeeds
And the body has the label "Keep activity for" over a USelectMenu (at most 260 px wide) with the items "30 days", "90 days", "365 days", "Forever" (values 30, 90, 365, 0) and a 12 px muted helper: "Older events are removed automatically." or, for Forever, "Nothing is removed automatically. The log grows over time."
And an info UAlert with icon lock is always shown: "Activity logs are append-only" / "Events can't be edited or deleted from the app. Only the retention period removes them."
And after a divider two read-only lines (120 px label then value; stacked on mobile) are shown in the user's local time: "Last cleanup" reads "6 Oct 2026, 03:00 · 142 events removed" ("1 event removed" for 1), or "Never" when activity_last_cleanup_at is null; "Next cleanup" reads "7 Oct 2026, 03:00" from activity_next_cleanup_at, or "Not scheduled" when it is null
And for Forever the last-cleanup line still shows the real last cleanup when one exists, and "Never" otherwise
And the periods are ordered 30 < 90 < 365 < Forever, and "shorter" means a lower rank than the saved period
And choosing a longer period, including Forever, sends PATCH /v1/settings with {"activity_retention_days": <value>} at once
And choosing a shorter period sends nothing and shows a warning UAlert with icon triangle-alert, title "Events older than {n} days will be removed", text "This happens at the next cleanup on {date}. It can't be undone." (the date is the saved settings' activity_next_cleanup_at, formatted like the line above) and the buttons "Apply {n} days" and "Cancel"
And a PATCH 200 returns the full settings, which refresh the select and the last and next lines
And any non-401 PATCH failure (400, 403, 500, network) shows an error UAlert with icon circle-x, title "Couldn't save the retention period", text "Your previous setting is still in place." and a "Try again" button, reverts the select to the saved value and leaves the lines unchanged; a 403 additionally refreshes the session, after which the card is gone
And "Try again" re-sends the failed value without asking for the shortening confirmation again
And while GET /v1/settings is loading for longer than 150 ms the card shows skeleton lines with aria-busy="true"
And when GET /v1/settings fails the card shows the Media cleanup card's alert, "Couldn't reach RenewCMS. Check your connection and try again." with "Try again", which re-runs the shared load
And while the browser is offline the select, "Apply" and "Cancel" are disabled; the page's offline banner copy is unchanged
Scenario Outline: Settings API, GET returns the next cleanup of the saved period
Given the saved retention is <days> days and the server time is <now> in a server zone of +02:00
When a verified user sends GET /v1/settings
Then the status is 200
And the body has "activity_next_cleanup_at" <next> and the other four fields
Examples:
| days | now | next |
| 90 | 2026-10-06T10:00:00+02:00 | "2026-10-07T03:00:00+02:00" |
| 90 | 2026-10-06T02:59:59+02:00 | "2026-10-06T03:00:00+02:00" |
| 90 | 2026-10-06T03:00:00+02:00 | "2026-10-07T03:00:00+02:00" |
| 30 | 2026-12-31T23:30:00+02:00 | "2027-01-01T03:00:00+02:00" |
| 365 | 2026-10-06T10:00:00+02:00 | "2026-10-07T03:00:00+02:00" |
| 0 | 2026-10-06T10:00:00+02:00 | null |
Scenario: Settings API, GET keeps the existing fields and adds exactly one
Given the saved retention is 90 days, the last cleanup is 2026-10-06T01:00:00Z with 142 events removed
When a verified user sends GET /v1/settings
Then the body is a JSON object with exactly the keys version, activity_retention_days, activity_last_cleanup_at, activity_last_cleanup_removed and activity_next_cleanup_at
Scenario: Settings API, GET is open to every verified user
Given a verified admin or editor sends GET /v1/settings
Then the status is 200 and the body includes activity_next_cleanup_at
Scenario Outline: Settings API, PATCH returns the next cleanup of the new period
Given the saved retention is <from> days and the user is the owner
When the owner sends PATCH /v1/settings with {"activity_retention_days": <to>}
Then the status is 200 and activity_retention_days is <to>
And activity_next_cleanup_at is <next>
Examples:
| from | to | next |
| 90 | 30 | the next 03:00 server time |
| 90 | 0 | null |
| 0 | 365 | the next 03:00 server time |
| 90 | 90 | the next 03:00 server time |
Scenario: Settings API, PATCH with the saved value changes nothing
Given the saved retention is 90 days
When the owner sends PATCH /v1/settings with {"activity_retention_days": 90}
Then the status is 200, nothing is saved and no audit event is recorded
Scenario: Settings API, PATCH by a non-owner is refused and changes nothing
Given a verified admin
When the admin sends PATCH /v1/settings with {"activity_retention_days": 30}
Then the status is 403 with the text "Only the owner can do this." and the saved retention is unchanged
Scenario Outline: Settings API, PATCH rejects invalid input
When the owner sends PATCH /v1/settings with <body>
Then the status is 400 with the text of apperr.BodyErrorMessage and nothing is saved
Examples:
| body |
| {"activity_retention_days": 7} |
| {"activity_retention_days": "30"} |
| {} |
| not json |
Scenario: Settings use cases compute the next cleanup from their clock
Given GetActivityRetentionUseCase and UpdateActivityRetentionUseCase are built with a fake settings repository and a fixed clock
Then GetActivityRetention returns Settings whose NextCleanupAt is the next 03:00 after the clock, or nil for Forever
And UpdateActivityRetention returns the new period with its own NextCleanupAt
And UpdateActivityRetention(7) returns an error satisfying errors.Is(err, audit.ErrInvalidRetention) and saves nothing
Scenario Outline: Home, owner or admin, the last five events with the actor name
Given the session role is "<role>" and GET /v1/activity?size=5 resolves 200 with 5 events, the first being Marta Alves publishing the article "Spring release" 2 minutes ago
When the user navigates to /home
Then the card title is "Recent activity" and "View all" links to /activity
And row 1 reads "Published the article Spring release" with "Spring release" in semibold, over "Marta Alves · 2 min ago", with a 24 px avatar "MA"
And exactly 5 rows are shown and GET /v1/activity was requested once with size=5 and no other parameter
Examples:
| role |
| owner |
| admin |
Scenario Outline: Home, regular user, own events only and no actor name
Given the session role is "<role>" and GET /v1/activity?size=5 resolves 200 with 3 own events
When the user navigates to /home
Then the card title is "Your recent activity"
And each row's meta line is only the time, for example "2 min ago"
And no actor parameter was sent
Examples:
| role |
| editor |
| absent |
Scenario Outline: Home rows, failure and security markers
Given an event with outcome "<outcome>" and security <security>
Then its row shows <marker>
Examples:
| outcome | security | marker |
| success | false | no badge and no shield |
| failed | false | a "Failed" badge |
| success | true | a shield icon and no badge |
| failed | true | a "Failed" badge and no shield |
Scenario Outline: Home rows, the time label follows the day
Given the current time is 2026-10-06 15:00 local and an event created <when>
Then the meta time reads "<label>"
Examples:
| when | label |
| 2026-10-06 14:58 | 2 min ago |
| 2026-10-06 12:00 | 3 h ago |
| 2026-10-05 14:20 | Yesterday, 14:20 |
| 2026-10-03 14:20 | 3 Oct 2026, 14:20 |
Scenario: Home rows, a long sentence is clamped to two lines
Given an event on an article whose title has 150 characters
Then the sentence is clamped to two lines with the title in semibold and the meta line stays on one line
Scenario: Home rows, an unknown action and a failed login
Given an event with action "article.archive" and an "auth.login_failed" event for "alex@example.com"
Then the first row shows "article.archive", the second "Failed login attempt for alex@example.com" with a "Failed" badge and the user-round-x avatar, and nothing throws
Scenario: Home rows are not interactive
Then a click on a row navigates nowhere and opens nothing
Scenario: Home card, skeleton rows only after 150 ms
Given GET /v1/activity?size=5 has not resolved
When the user navigates to /home
Then nothing is drawn in the card body for the first 150 ms
And after 150 ms five skeleton rows are shown and the card is aria-busy="true"
When the request resolves 200 with 5 events
Then the skeletons are replaced by 5 rows
And a response in under 150 ms never shows a skeleton
Scenario Outline: Home card, error shows a retryable alert that leaves the page alone
Given GET /v1/activity?size=5 <failure> and GET /v1/article/ resolves 200 with 6 articles
When the user navigates to /home
Then the card shows an alert "Couldn't load recent activity" with a "Try again" button
And the stat tiles "6" "articles", "4" "online", "2" "drafts", the recent article list and "Connect your website" are shown as in home.feature's Default state
When the user activates "Try again"
Then GET /v1/activity?size=5 is sent again and, on 200 with events, the rows replace the alert
Examples:
| failure |
| resolves with a 500 response |
| resolves with a 403 response |
| never reaches the server |
Scenario Outline: Home card, empty when there are no events yet
Given GET /v1/activity?size=5 resolves 200 with items [] and total 0 and the role is "<role>"
When the user navigates to /home
Then the card shows the history icon, "No activity yet" and "<text>"
And "View all" is still shown
Examples:
| role | text |
| owner | Actions on your site will appear here. |
| editor | Your actions will appear here. |
Scenario: Home card and article list are independent
Given GET /v1/article/ fails with a 500 and GET /v1/activity?size=5 resolves 200 with 5 events
Then the articles alert "Couldn't load your articles" and the 5 activity rows are both shown
And with GET /v1/article/ resolving [] and GET /v1/activity?size=5 failing, the articles' Empty state and the card's error are both shown
Scenario: Home layout is two columns from lg and stacked below
Given the viewport is lg or wider
Then the header and the update banner span the full content width and the grid below them spans the same full width with the article cards on the left and the activity card 420 px wide in the far-right column, 16 px apart, its top aligned with the top of the article cards
Given the viewport is below lg
Then the card sits above the stat tiles, below the banner, at full width
Scenario: Home card, "View all" opens the activity page
When the user activates "View all"
Then the router navigates to /activity
Scenario: Settings retention is hidden for everyone but the owner
Given GET /v1/settings resolves 200 with activity_retention_days 90
When an "admin" (or an "editor", or an absent role) navigates to /settings
Then no "Retention" card and no "Keep activity for" select is in the page
And the Version, Updates and Media cleanup cards are unchanged
And GET /v1/settings was requested once
Scenario: Settings retention, Default with 90 days
Given the role is "owner" and GET /v1/settings resolves 200 with activity_retention_days 90, activity_last_cleanup_at 6 Oct 2026 03:00 local, activity_last_cleanup_removed 142 and activity_next_cleanup_at 7 Oct 2026 03:00 local
When the owner navigates to /settings
Then the third card reads "Retention" and "How long activity is kept."
And the select shows "90 days" with the helper "Older events are removed automatically."
And the lock alert "Activity logs are append-only" is shown
And the lines read "Last cleanup" "6 Oct 2026, 03:00 · 142 events removed" and "Next cleanup" "7 Oct 2026, 03:00"
And no "Saved" is shown, no PATCH was sent and GET /v1/settings was requested once
Scenario: Settings retention, before the first cleanup
Given activity_last_cleanup_at is null and activity_next_cleanup_at is set
Then "Last cleanup" reads "Never" and "Next cleanup" shows the date
Scenario: Settings retention, skeleton lines after 150 ms
Given GET /v1/settings has not resolved and the role is "owner"
When the owner navigates to /settings
Then nothing is drawn in the card body for the first 150 ms, then skeleton lines with aria-busy="true"
Scenario: Settings retention, the settings request fails
Given GET /v1/settings fails with a 500
Then the card shows "Couldn't reach RenewCMS. Check your connection and try again." with a "Try again" button that re-runs the shared GET /v1/settings
Scenario Outline: Settings retention, a longer period saves at once
Given the saved period is <from> and the owner chooses "<to>"
When PATCH /v1/settings resolves 200 with activity_retention_days <value> and the matching activity_next_cleanup_at
Then PATCH was sent once with {"activity_retention_days": <value>} and no warning alert is shown
And the status reads "Saving…" while in flight and "Saved" with a check afterwards, fading after 2 s
And the select shows "<to>" and the "Next cleanup" line shows the returned value
Examples:
| from | to | value |
| 90 days | 365 days | 365 |
| 30 days | 90 days | 90 |
| 90 days | Forever | 0 |
Scenario: Settings retention, Forever changes the helper and the lines
Given the saved period is 90 days and the owner chooses "Forever" and PATCH resolves 200 with activity_retention_days 0, activity_next_cleanup_at null and a last cleanup of 6 Oct 2026 03:00 with 142 removed
Then the helper reads "Nothing is removed automatically. The log grows over time."
And "Next cleanup" reads "Not scheduled" and "Last cleanup" still reads "6 Oct 2026, 03:00 · 142 events removed"
Scenario: Settings retention, Forever and never cleaned
Given GET /v1/settings resolves 200 with activity_retention_days 0, activity_last_cleanup_at null and activity_next_cleanup_at null
Then "Last cleanup" reads "Never" and "Next cleanup" reads "Not scheduled"
Scenario: Settings retention, shortening asks first
Given the saved period is 90 days and activity_next_cleanup_at is 7 Oct 2026 03:00 local
When the owner chooses "30 days"
Then no PATCH is sent and the select shows "30 days"
And a warning alert shows "Events older than 30 days will be removed" and "This happens at the next cleanup on 7 Oct 2026, 03:00. It can't be undone." with "Apply 30 days" and "Cancel"
When the owner activates "Apply 30 days" and PATCH resolves 200 with activity_retention_days 30
Then PATCH was sent once with {"activity_retention_days": 30}, the alert is gone and "Saved" shows
Scenario: Settings retention, shortening cancelled
Given the warning for 30 days is shown on a saved 90
When the owner activates "Cancel"
Then the alert is gone, the select reads "90 days" again and no PATCH was ever sent
Scenario Outline: Settings retention, shortening is relative to the saved period
Given the saved period is <saved>
When the owner chooses "<chosen>"
Then <outcome>
Examples:
| saved | chosen | outcome |
| Forever | 365 days | the warning "Events older than 365 days will be removed" shows |
| 365 days | 90 days | the warning "Events older than 90 days will be removed" shows |
| 90 days | 90 days | nothing happens and no request is sent |
| 30 days | Forever | PATCH is sent at once |
Scenario: Settings retention, choosing a longer period while the warning is open drops it
Given the warning for 30 days is shown on a saved 90
When the owner chooses "365 days"
Then the warning disappears and PATCH is sent once with 365
When the owner then chooses "30 days" and again "365 days" before the first request settles or after it
Then no stale warning is ever left on screen
Scenario Outline: Settings retention, save error reverts the control and Try again retries
Given the saved period is 90 days and the owner chooses "365 days"
When PATCH /v1/settings <failure>
Then an error alert shows "Couldn't save the retention period" and "Your previous setting is still in place." with "Try again"
And the select shows "90 days" again, the lines are unchanged and no "Saved" appears
When the owner activates "Try again" and PATCH resolves 200 with 365
Then PATCH was sent a second time with 365 and "Saved" replaces the alert
Examples:
| failure |
| resolves with a 500 response |
| resolves with a 400 response |
| resolves with a 403 response |
| never reaches the server |
Scenario: Settings retention, Try again after Apply does not ask again
Given the warning for 30 days is shown and the owner activates "Apply 30 days"
When PATCH resolves 500
Then the warning is gone, the error alert is shown and the select reads the saved period
When the owner activates "Try again" and PATCH resolves 200 with 30
Then PATCH was sent a second time with 30 and no warning reappears
Scenario: Settings retention, an owner demoted while the page is open
Given PATCH /v1/settings resolves 403 and GET /v1/auth/me then resolves with role "admin"
Then the error alert is shown and, after the session refresh, the Retention card is no longer rendered
Scenario: Settings retention, offline disables the controls
Given the owner is on /settings and the browser goes offline
Then the select, and "Apply" and "Cancel" when shown, are disabled and nothing is sent
And the page's offline banner keeps its existing copy
Scenario: Settings retention, a 401 is global
Given GET or PATCH /v1/settings resolves 401
Then useApi redirects to /login?redirect=/settings&reason=expired and no card alert is shown
Scenario: The French interface
Given the language is French
Then every string of both cards is French and the Home rows use the French sentence templates of activity_screen.feature
Scenario: Existing behaviour is unchanged
Then every scenario of home.feature, settings_page.feature (except its exact-body statement, superseded above) and the Media cleanup scenarios still holds with GET /v1/activity?size=5 mocked and the settings mock widened with the four activity_* fields
@@ -0,0 +1,74 @@
# QA: Recent activity card on Home and Retention card on Settings
## Implementation note for coder (informational, not a QA assertion)
- Backend: add NextCleanupAt (nullable time) to the audit settings entity with a domain method computing the next 03:00 (audit.CleanupHour) strictly after a given time, in that time's location; nil when RetentionDays is audit.ForeverRetentionDays. GetActivityRetentionUseCase and UpdateActivityRetentionUseCase get an injected `now func() time.Time` (wired in api/dependecyInjection.go) and populate it. settingsResponse in api/handlers/settings gains `activity_next_cleanup_at` (`*time.Time`, RFC 3339, null when nil). No logic in the handler, none in the SPA.
- Home: HomeView mounts a RecentActivityCard calling GET /v1/activity?size=5 once, reusing ActivityActor, ActivitySentence and lib/activityTime.
- Settings: widen WorkspaceSettings with the four activity_* fields, add a PATCH helper to useSettings, render RetentionCard after MediaCleanupCard for session.role === 'owner' only. No second GET /v1/settings.
- features/settings_page.feature and its qa.md say the body is exactly {"version"}; this feature supersedes that statement, so update those tests to the new five-key body.
## Automated, Go (httptest, in-memory fakes, `task test`)
Build `settings.NewHandler(GetSettingsUseCase, GetActivityRetentionUseCase, UpdateActivityRetentionUseCase)` over a fake audit.SettingsRepository (records saved retention and last cleanup) and a fake audit.Recorder, with a fixed fake clock. Mount on a fresh chi.NewRouter mirroring NewSettingsRouter: GET "/" behind jwtauth.Verifier + Authenticator (test jwtauth.New("HS256", ...)); PATCH "/" additionally behind the owner check using a fake user repository holding an owner, an admin and an editor. Call validator.InitValidator() first. No live server, no SQLite file. Fake type names must be unique across test files.
- G1. GET table (clock in a +02:00 zone): retention 90 at 10:00 gives next = next day 03:00+02:00; at 02:59:59 gives same day 03:00; at exactly 03:00:00 gives next day 03:00; 30 at 2026-12-31T23:30 gives 2027-01-01T03:00; 365 at 10:00 gives next day; retention 0 gives JSON null (assert the key is present with value null, not absent).
- G2. GET body has exactly the five keys (decode into map[string]any, assert the key set); last cleanup 2026-10-06T01:00:00Z and 142 preserved.
- G3. GET is 200 for owner, admin and editor tokens; 401 without a cookie.
- G4. PATCH owner 90->30: 200, activity_retention_days 30, next cleanup non-null at the next 03:00; the fake repository holds 30; one audit event with params from 90, to 30.
- G5. PATCH owner 90->0: 200, next null. PATCH 0->365: next non-null.
- G6. PATCH owner 90->90: 200, next non-null, no repository save, no audit event.
- G7. Ownership: PATCH as admin and as editor: 403 with "Only the owner can do this."; repository unchanged; no audit event. PATCH without a cookie: 401.
- G8. Invalid: {"activity_retention_days": 7}, "30" (string), {}, malformed JSON: each 400 with the apperr.BodyErrorMessage text (plain text via http.Error), repository unchanged.
- G9. Use-case level (no HTTP): GetActivityRetention returns NextCleanupAt per G1 rows; UpdateActivityRetention(7) returns an error satisfying errors.Is(err, audit.ErrInvalidRetention) and saves nothing; a repository Get error propagates and the handler answers 500 "Internal server error".
- G10. Domain: NextCleanupAt table including a DST-change day in a named zone (for example Europe/Paris, 2026-10-25), asserting it is still 03:00 local on the right date.
## Automated, SPA (Vitest + Vue Test Utils, `task test:web`)
Suggested files: web/src/views/__tests__/HomeActivityCard.spec.ts and SettingsRetention.spec.ts. Mock useApi per case; no real backend. Freeze the clock with vi.setSystemTime only where a time label is asserted (these specs are not visual).
Home (mount HomeView with a resolved session; GET /v1/article/ mocked with the 6-article fixture):
1. Owner and admin: 5 events, title "Recent activity", "View all" links to /activity; row 1 text with semibold target, "Marta Alves · 2 min ago", avatar "MA"; the activity mock was called once with exactly size=5 and no other parameter; /summary and /export never called.
2. Regular user (editor, absent role): "Your recent activity"; meta lines are time only; no actor parameter.
3. Marker table over outcome x security (no badge and no shield, Failed badge, shield only, badge only).
4. Time label table (2 min ago, 3 h ago, "Yesterday, 14:20", "3 Oct 2026, 14:20") with fixed time; the title attribute holds the full local time.
5. Clamp class on a 150-character title; unknown action "article.archive" renders the code; auth.login_failed sentence with Failed badge and user-round-x avatar.
6. Rows are inert: clicking one changes no route and opens nothing.
7. Skeleton only after 150 ms (fake timers): none before, 5 skeleton rows and aria-busy after, replaced on resolve; a fast response never shows one.
8. Error table (500, 403, network): role="alert", title "Couldn't load recent activity", no description, "Try again"; stat tiles, article list and "Connect your website" still rendered; retry sends a second GET and recovers.
9. Empty: both copies by role; "View all" still present.
10. Independence in both directions (articles 500 + activity 200; articles [] + activity 500).
11. "View all" navigates to /activity (test router).
12. Layout classes: grid with the lg two-column template and the 420 px column, and no max-width class on the Home wrapper or the grid (the grid spans the full content width); DOM order puts the card first below lg (real pixels are manual).
13. Existing home specs (HomeView, HomeUpdateBanner, HomeUpdateReady) keep passing with the activity mock added to the shared mount helper.
Settings (mount SettingsView, GET /v1/settings mocked with the five-key body):
14. Hidden for admin, editor and absent role; GET /v1/settings called once.
15. Owner Default: header, "90 days", helper, lock alert, "Last cleanup 6 Oct 2026, 03:00 · 142 events removed", "Next cleanup 7 Oct 2026, 03:00" (from activity_next_cleanup_at; the SPA computes nothing); no PATCH; "1 event removed" singular.
16. last_cleanup_at null: "Never". Forever with null/null: "Never" and "Not scheduled". Forever with a past cleanup: the real last line and "Not scheduled".
17. Skeleton after 150 ms with aria-busy; load error alert and "Try again" re-runs the shared GET.
18. Longer periods (90->365, 30->90, 90->Forever): one PATCH with {"activity_retention_days": N}, "Saving…" then "Saved", gone after 2 s (fake timers), no warning; the returned next cleanup replaces the line; a Forever response with next null shows "Not scheduled".
19. Shortening: no PATCH, warning copy with the date taken from the loaded activity_next_cleanup_at; Apply sends once; Cancel reverts the select with no PATCH; table Forever->365, 365->90, 90->90 (no-op), 30->Forever (immediate); a longer choice while the warning is open drops it and leaves no stale warning after 30 days then 365 days again.
20. Save error x (500, 400, 403, network): alert copy, select reverts, lines unchanged; Try again re-sends the same value without re-confirming; success shows "Saved"; error after Apply also clears the warning; a 403 triggers a session refresh and the card disappears when the role is no longer owner.
21. Offline (mock useOnline false): select, Apply and Cancel disabled; nothing sent; page offline banner text unchanged.
22. 401 handled globally: no local alert.
23. French key parity: every new string has a messages.ts entry.
24. Keyboard: the select opens with Enter or Space, arrows and Enter choose, Escape closes; Apply then Cancel reachable in order after the select.
25. Existing SettingsView, SettingsMediaCleanup and Settings update specs keep passing with the widened mock body.
## Visual (task visual:check)
- Neither card can be pixel-compared (dashed placeholders); no new visual state is added. The card sub-states are not `###` headings in home.md or settings.md, so the tool should not iterate them; confirm on the first run.
- Existing Home states and owner-session Settings states must still pass at 10 % with the card present. Every Home setup in web/visual/states.ts needs GET /v1/activity?size=5 mocked (an unmocked /v1 call fails the gate), and every owner Settings setup needs the widened GET /v1/settings body. Layout changes may exceed the gate: measure first, then ask the user for a per-state `exception` (needs approvedBy). Do not add any exception beforehand.
- Time-dependent states stay `skip: 'clock-sensitive'`; no Vitest spec freezes the date for a visual claim.
## Manual verification
- [ ] Real GET /v1/settings and PATCH /v1/settings against `task dev` with a live SQLite file: five keys, next cleanup at the next 03:00 server time, null after switching to Forever, a timestamp after leaving Forever; as an admin PATCH is 403, as the owner a bad value is 400.
- [ ] Browser in a time zone different from the server's: the "Next cleanup" and "Last cleanup" lines both show the user's local time of the same instants; the warning date matches the "Next cleanup" line.
- [ ] Real daily cleanup (or a forced run) updates "Last cleanup" and moves "Next cleanup" a day forward.
- [ ] Real GET /v1/activity?size=5 as owner, admin and editor (server-side own-event scoping), including a site with only events older than 7 days (reads "No activity yet").
- [ ] Home at 1440 and 390 px, light and dark: 420 px column in the far-right of the full content width, 16 px gap, row clamping, "View all" 44 px touch target; the article column fills the remaining width and still reads well.
- [ ] Keyboard and focus: Home tab order "New article", article rows, "View all"; Settings select, Apply, Cancel in order and focus back on the select after Cancel.
- [ ] Hover, focus rings, the 2 s "Saved" fade and reduced motion.
+693
View File
@@ -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 "<type>" named "<name>", action "<action>", outcome
"<outcome>" and security <security>
Then the Actor cell shows <actor>
And the Action cell shows "<sentence>" with the target name in semibold, and <marker>
And the Outcome cell shows "<badge>"
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 "<action>", outcome "<outcome>", params <params> and target
"<target>"
When the language is <language>
Then the sentence reads "<sentence>" with "<target>" 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 <total> and previous_total <previous>
When the user navigates to /activity
Then the Total card reads "<total>" and "<subline>" 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 <failure> 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
<failure>
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>" card
Then <effect>
And <state>
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 <action>
Then GET /v1/activity is sent with <query>, 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 <failure>
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 "<source>"
When its details open
Then Source reads "<label>"
Examples:
| source | label |
| web | Web app |
| api_key | API key |
| system | System task |
Scenario: Details — Previous and Next move through the filtered list across pages
Given the list is on page 1 of size 25 with total 1284 and the first row's details are open
Then "Previous event" is disabled and the footer reads "Event 1 of 1,284"
When the user activates "Next event" 24 times
Then the footer reads "Event 25 of 1,284" with no extra request
When the user activates "Next event" once more
Then GET /v1/activity is sent with the same filters, page=2 and size=25
And the panel shows the first item of page 2, the footer reads "Event 26 of 1,284" and the
pager under the table reads "Showing 26–50 of 1,284"
When the user activates "Previous event"
Then GET /v1/activity is sent with page=1 and the panel shows the last item of page 1 as
"Event 25 of 1,284"
And on the last event "Next event" is disabled
Scenario: Details — a failed neighbour page keeps the panel on the current event
Given the last item of page 1 is open
When the user activates "Next event" and GET /v1/activity resolves 500
Then the panel still shows the same event, the button is enabled again and the list is
unchanged
Scenario: Details — below md the slide-over is a bottom sheet
Given the Default state has loaded below md
When the user activates the second card
Then a bottom sheet opens, 88 % high, with a handle, the title "Activity details", "2 of
1,284" in the header, and full-width "Previous event" and "Next event" buttons
And there is no "Event 2 of 1,284" footer text
Scenario: My activity — a role that is not owner or admin sees its own events
Given the session role is "editor" (or absent) and GET /v1/activity resolves 200 with 25
items and total 41
When the user navigates to /activity
Then the title reads "My activity", the subtitle "Everything you've done on this site." and
the breadcrumb "My activity"
And no summary strip, no Person filter and no "Export CSV" are rendered
And the table has the columns When, Action, Target, Outcome and no Actor column
And the cards show no actor line below md
And GET /v1/activity is sent with range=7d, page=1, size=25 and no actor parameter, and
GET /v1/activity/summary is never sent
And the pager reads "Showing 1–25 of 41"
Scenario: My activity, empty — the user has not done anything yet
Given the session role is "editor" and GET /v1/activity resolves 200 with items [] and total 0
When the user navigates to /activity
Then an empty panel with icon history shows "No activity yet" and "Your actions will appear
here once you start working on the site."
Scenario: My activity, details — the slide-over on one of the user's own events
Given the session role is "editor" and the Default list has loaded
When the first row's details open
Then the footer reads "Event 1 of 41", "Previous event" is disabled and the panel is the same
as for an owner, with the Actor row showing the user's own name
Scenario: The French interface composes sentences and chrome in French
Given the language is French and the Default state has loaded as owner
Then the title reads "Activité", the strip labels, filter placeholders, badges, pager and
"Exporter en CSV" are in French, the sentences use the French templates and day
separators read "Aujourd'hui", "Hier" or "3 oct. 2026"
And the Security sub-line reads "11 signalés"
And every new English string of this screen has a French entry in messages.ts
Scenario: Hover shows the exact time
Given the Default state has loaded
Then each When cell has a title attribute holding the full local time with seconds
+257
View File
@@ -0,0 +1,257 @@
# QA: Activity screen, details slide-over and My activity
## Implementation note for coder (informational, not a QA assertion)
The activity API is already built and covered by features/activity-log-backend.qa.md and
features/activity-log-producers.qa.md: this slice has no Go change and no HTTP-level Go section.
The shell specs that list the navigation are amended to seven destinations (Home, Articles, Media,
Users, Activity, Integration, Settings): features/dashboard_shell_branding.feature,
features/media_browse.feature, features/users_screen.feature and .qa.md,
features/settings_page.feature and .qa.md, features/profile_page.feature. Their Vitest tests
(DashboardShell.spec.ts, where Settings moves from index 5 to 6) must be updated in the same change.
Export needs the response body as a file, which `useApi().request` does not return (it calls
`response.json()`). Add a raw or blob read to `useApi` that keeps the global 401 handling, builds
an object URL and clicks a temporary anchor with `download="activity.csv"`. The toasts use
`useToast().add({ color, icon, title })` with the one sentence as the title.
The page needs a date-range popover and a Custom date pair in the mobile sheet. The clamp ("Show
more") is measured from the rendered element, so tests stub `scrollHeight` and `clientHeight`.
Every new English string needs a French entry in `web/src/i18n/messages.ts`. Sentences are
composed by a composable (`useActivitySentence`) that returns segments so the target name can be
rendered semibold. The Security sub-line is "{n} flagged" with n = `summary.security`, French
"{n} signalés".
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi`'s `request` (and the export transport) per case, matching on method, path and parsed
query (`URLSearchParams`); no real backend. Mount ActivityView inside DashboardShell with a
resolved verified session (id 2, username "ines", display_name "Inès Duarte", role "owner") and a
test router at /activity; the regular-user cases use role "editor" (and one with no role). Run
each layout case at a desktop and a below-md width (mock `matchMedia`, see
`components/__tests__/viewport.ts`). Timers: use `vi.useFakeTimers({ toFake: ['setTimeout',
'clearTimeout'] })` so the 150 ms skeleton delay and the 300 ms search debounce can be advanced
while `Date` stays real. No test freezes or asserts the system date.
Clock-independence rules for fixtures: every event in the Vitest fixtures is dated 3 Oct 2026 or
earlier, created at 12:00 to 13:00 UTC so its local day is the same in any time zone from UTC-11
to UTC+11; assert day separators with the absolute label "3 Oct 2026" and When cells only against
the pattern `^\d{2}:\d{2}$` and a non-empty `title`; never assert "Today", "Yesterday" or a
relative "N min ago". UTC lines can be asserted exactly; the local time line only with its
"(your time)" suffix.
Fixtures: `activityItems` (a 15-item list built from the board's events, mapped to the API shape
with the action codes of the "Visual" section), `summaryDefault` (total 1284, previous_total
1146, active_people {users 7, api_keys 2}, failed 6, security 11, series total [4,6,5,8,7,9,6],
active [5,6,6,7,7,8,9], failed [0,1,0,2,1,0,2], security [1,0,2,1,3,2,2]), `summaryZero` (total
214, previous_total 223, active_people {4, 1}, failed 0, security 0, series failed and security
zeros) and `summaryNone` (all zeros). The list response is `{ items, total, page, size }`.
1. **Navigation** — sidebar and drawer list Home, Articles, Media, Users, Activity, Integration,
Settings in order; "Activity" has the `i-lucide-history` icon, links to /activity, has
aria-current="page" on /activity and none elsewhere; breadcrumb and mobile title "Activity".
2. **Default (owner)** — assert header texts, enabled "Export CSV", the four strip cards (labels,
"1,284", "9", "6", "11", sub-lines "12% vs previous 7 days" with the trending-up icon, "7
people, 2 API keys", "0.5% of all actions" with icon `circle-x`, "11 flagged" with icon
`shield-alert`), the filter bar (placeholder, "All people", "All categories", Outcome All
selected, 7 days selected), no chip, the pager text "Showing 1–25 of 1,284", and that `request`
was called exactly twice at mount with the expected queries (`range=7d&page=1&size=25` and
`range=7d`).
3. **Rows, Examples-driven** — one fixture row per Examples row of the feature; assert the
Actor cell (initials, key or cog or user-round-x icon, badge, "Not signed in"), the sentence
text and that the target name is in a semibold element, the shield marker only on security
rows, and the Outcome badge word.
4. **Target cell** — deleted (struck-through class, "Deleted" badge, type line), living (link
with `href` "/article/8/edit", "/media", "/users", "/integration/api-keys" per type, plain text
for "release"), and null ("—"); clicking the link navigates and does not open the details.
5. **Day grouping and When cell** — fixture across 3 Oct and 2 Oct 2026; assert two separators
"3 Oct 2026" and "2 Oct 2026" in order, the When cell pattern and `title`.
6. **Sentences, Examples-driven** — for each Examples row set the locale (`setLocale`), render
and assert the exact sentence, including plurals, `failed_groups`, "Forever", the dropped slot
for a missing target name, the unknown code `article.archive`, and the success sentence used
for an unlisted failed action (`auth.login` failed). Add a completeness test: every action in
the catalogue has an English and a French template, and every key used on the page exists in
`messages.ts`.
7. **Loading** — unresolved requests; at 149 ms nothing in place of strip and table; at 150 ms 4
skeleton cards, 9 skeleton rows (5 cards below md), `aria-busy="true"`, no pager; resolve and
assert the Default content.
8. **Zero-value strip** — `summaryZero`; assert "214" "4% vs previous 7 days" with the
trending-down icon, "5" "4 people, 1 API key", "0" "Nothing failed" and "0" "Nothing to flag"
with `circle-check`, no error or warning fill class, bars at 2 px.
9. **Total sub-line without a comparison, Examples-driven** — previous_total 0 gives "No earlier
data" and equal totals give "No change", both without a trend icon; French "Aucune donnée
antérieure" and "Aucun changement"; ranges Today, 30 days and Custom give "vs previous day",
"vs previous 30 days" and "vs previous period".
10. **Empty, no activity yet** — `summaryNone`, items []; assert "No data yet" on two cards, the
calm lines on the other two, the empty panel copy and icon, a disabled "Export CSV", no table
header and no pager.
11. **Empty, filters match nothing** — type "sitemap", choose Category "Media" and Outcome
"Failed", resolve items []; assert chips, empty panel copy, "Clear filters", enabled export;
click "Clear filters" and assert a request with `range=7d&page=1&size=25` only.
12. **Error, Examples-driven** — list rejects with `ApiError(500)`, `ApiError(400)`, then
`NetworkError`, summary resolves; assert the strip, the alert copy, "Try again", a disabled
export and no table; click "Try again"; assert the list request is repeated and the Default
rows replace the alert.
13. **Summary alone fails** — summary rejects with `ApiError(500)`, `ApiError(403)`, then
`NetworkError`; assert the strip is absent, nothing is drawn in its place, and the list is
fully rendered.
14. **Forbidden** — list rejects with `ApiError(403)` and a verified session; assert no export
button, no strip, no filter bar, the lock panel copy, and that "Back to home" navigates to
/home. Also `ApiError(401)` triggers the global redirect and no panel.
15. **Strip cards, Examples-driven** — click each card; assert the effect and `aria-pressed`
(Failed: request with `outcome=failed&page=1`, chip "Outcome: Failed"; Security: request with
`security=true`, chip "Security events"; Total: filters cleared; Active people: no request)
and that the summary is not requested again.
16. **Filtered, card applied** — Category "Authentication", 30 days, then the Failed card; assert
the chip texts and order, "Clear all", the summary requested only once for `range=30d`, the
pager "Showing 1–6 of 6", and below md the Filters badge "3".
17. **Filter bar, Examples-driven** — for each Examples row assert the exact query, `page=1` after
starting on page 3, and the 300 ms debounce with fake timers (no request at 299 ms, one at
300 ms; Enter applies at once and the pending debounce does not send a second request).
18. **Custom dates** — pick two dates in the popover (stub the calendar's model); assert no request
after the first date, then `range=custom&from=2026-09-01&to=2026-09-30` on the list and the
summary, and the chip "Dates: 1 – 30 Sep 2026"; also "28 Sep – 3 Oct 2026".
19. **Chips** — remove one chip, then "Clear all"; assert the queries and that the summary is
requested again only when the range changes back.
20. **Person options** — assert no `GET /v1/user/` or `GET /v1/api-key/` call at mount, both on
first open of the menu, none on the second; assert group order and the three query values.
21. **Pagination** — next page, size 50, last page "Showing 1,276–1,284 of 1,284" with next
disabled; mobile "Page 1 of 52" and previous and next.
22. **Mobile list and sheet** — below md assert cards (actor line, sentence, badges, "Target
deleted"), "Filters" with the badge count, the sheet content and footer ("Show 6 results", "Show
1 result"), two date fields for Custom, and that "Show … results" closes it.
23. **Export success** — click "Export CSV" with filters applied; assert one request to
`/v1/activity/export` with the list filters and no `page` or `size`, "Preparing file…" and
disabled while pending, an object URL created and an anchor clicked with
`download="activity.csv"`, the success toast (color success, icon `i-lucide-circle-check`,
title "Export started. Your file will download shortly."), and the button back to "Export CSV".
24. **Export failure, Examples-driven** — 500, 403 and `NetworkError`; assert the error toast
(color error, icon `i-lucide-circle-x`, title "Couldn't export activity. Try again."), no
download, button re-enabled.
25. **Details open and close** — click, then Enter on a focused row; assert the title, the selected
row class, the footer "Event 2 of 1,284"; close with Escape, the close button and the scrim and
assert focus on the row.
26. **Details content, Examples-driven** — the six board events: Edit with a diff (Title row with
both values and the "Body edited (+312 characters)" row, "(+1 character)", "(-48 characters)"),
long text (stub overflow; "Show more" on that row only, toggles to "Show less"), failed with a
long title (alert "Why it failed", wrapping), deleted target (struck-through name, "Deleted"
badge, explanation, no Changes), system event ("System" badge, "System task", two "Not
applicable"), failed login ("Not signed in", "Security" badge, reason, IP and Browser). Assert
the field labels and the Draft and Published values for `online`, the raw code for an unknown
field, and that no body text is ever rendered.
27. **Details times** — `created_at` "2026-10-06T10:42:18Z": the UTC line is exactly "6 Oct 2026,
10:42:18 UTC" and the local line ends with "(your time)".
28. **Previous and Next across pages** — first item: previous disabled, "Event 1 of 1,284"; 24
nexts without a request; the 25th next sends `page=2&size=25` with the same filters, shows
page 2's first item, "Event 26 of 1,284" and the pager "Showing 26–50 of 1,284"; previous
sends `page=1` and shows "Event 25 of 1,284"; the last event disables next; a 500 on the
neighbour page keeps the panel on the current event and re-enables the button; the position
also follows a non-default size (size 50, page 2, index 0 is "Event 51").
29. **Details below md** — the bottom-sheet variant: header "2 of 1,284", full-width buttons, no
footer text.
30. **My activity, Examples-driven** — role "editor" then no role: title, subtitle, breadcrumb "My
activity", no strip, no Person filter, no export, no Actor column or card actor line; assert no
request to `/summary` or `/export` and no `actor` parameter; pager "Showing 1–25 of 41"; empty
state copy; details "Event 1 of 41" with previous disabled.
31. **French** — `setLocale('fr')`; assert the chrome strings (Activity "Activité", My activity
"Mon activité", Export CSV "Exporter en CSV", Preparing file… "Préparation du fichier…",
Total actions "Actions au total", Active people "Personnes actives", Failed actions "Actions
en échec", Security events "Événements de sécurité", Succeeded "Réussie", Failed "Échec", Not
signed in "Non connecté", Activity details "Détails de l'activité", Previous event and Next
event "Événement précédent" and "Événement suivant", "11 signalés"), the French sentences, the
plural and "Forever" ("Toujours") forms, and the day separator format.
32. **Hover time** — each When cell has a `title` that is non-empty and contains seconds.
## Visual (task visual:check -- activity, desktop and mobile, light and dark)
Reference: `docs/design/screens/activity.md`, board `docs/design/source/RenewCMS Screens
Activity.dc.html`, groups "Activity, owner and admin", "Details slide-over" and "My activity"
(the last two are already declared as `extraSources` in `web/visual/check.ts`); exact values in
`docs/design/source/ActivityFrame.dc.html`. Add each state to `visualStates.activity` in
`web/visual/states.ts` with the board's own sample data. The gate is hard: every capture of every
non-skipped state must be within 10 %, for both viewports and both themes (6 gated states, 24
captures). No state uses the `now` option or a frozen clock. Mocks are keyed by method and path
(`GET /v1/activity`, `GET /v1/activity/summary`, `GET /v1/auth/me`); use `bodyFor` to read the
query. Do not mock or call `/v1/user/` or `/v1/api-key/` (Person options load only when the menu
opens, which no state does), so there is no unmocked request.
Sessions: add `activityOwnerSession` (id 2, display_name "Inès Duarte", role "owner") and
`activityEditorSession` (id 3, display_name "Marta Alves", role "editor"), as the board draws them
in its sidebar chip. The board draws a 4-item sidebar for the regular user as illustrative; the
real shell keeps all 7 items, a known small difference in the My activity Empty capture.
Fixtures (all taken from the frame's script): `activityBoardEvents`, the 15 events of the frame in
its order, mapped to API items. Where the frame's sample has no real producer the nearest real
code is used and the sentence differs from the board (accepted; those rows only appear in skipped
states): 1 `article.publish`, 2 `article.edit` with title and body (+312) changes, 3
`auth.login_failed` (anonymous, name "alex@example.com", reason from the frame), 4
`api_key.revoke`, 5 `system.media_purge` (system, ip and user_agent null), 6 `invite.accept`
(replaces "Changed the role"), 7 `media.upload` by the API key "Website sync", 8 `media.delete`
(target not existing), 9 `article.publish` failed with the 150-character title, 10
`system.activity_cleanup`, 11 `article.edit` with long title values (replaces the "Site
description" setting diff), 12 `api_key.create`, 13 `user.update_profile`, 14 `media.upload`, 15
`auth.password_reset_completed`; times are the frame's local times minus one hour as UTC
(`created_at`), IPs and browsers as in the frame, security as in the frame. Summary fixtures are
the three in the Automated section; the strip of the Default state is "1,284 / 9 / 6 / 11" with
the sub-line "11 flagged". Total is 1284 with page size 25 for the owner and 41 for the editor.
- [ ] Loading: both requests with `hang: true`; the setup waits 150 ms or more so the skeleton
strip and rows are drawn before the capture.
- [ ] Empty, no activity yet: list `items: [], total: 0`, summary `summaryNone`; "Export CSV"
disabled.
- [ ] Empty, filters match nothing: the board applies Search "sitemap", Category "Media", Outcome
"Failed" (steps type and select them, or the mock answers with the filtered query), list
`items: [], total: 0`, summary Default.
- [ ] Error with retry: list 500, summary Default; the alert, disabled export, strip drawn.
- [ ] Forbidden (403): list 403, summary 403; session with a non-admin role as the board does
(`role: 'user'`), so no "Export CSV".
- [ ] My activity, Empty: `activityEditorSession`, list `items: [], total: 0`.
- [ ] skip: clock-sensitive. Default, large dataset: `skip: 'clock-sensitive'` (day separators and
"2 min ago" depend on the real clock). Prints SKIP, never investigated or date-frozen.
- [ ] skip: clock-sensitive. Zero-value summary strip, Filtered card applied and Mobile filters
sheet (all draw Today rows behind the strip or sheet).
- [ ] skip: clock-sensitive. The six Details slide-over states (Edit with a diff, Settings diff
long text, Failed action very long target title, Deleted target no diff, System-initiated no
human actor, Security event failed login) and the My activity states Default and Details:
the rows behind the scrim show relative times and the panel shows the browser's local
time. Their Vitest coverage is cases 3, 5, 25 to 30.
- [ ] Layout, from `ActivityFrame.dc.html`, not `components.md`: header row is the title block
(20 px/28 px title) on the left and the 32 px (44 px mobile) "Export CSV" button on the right,
aligned to the bottom; the strip is 4 columns with a 12 px gap on desktop and a clipped row
of 156 px cards with an 8 px gap on mobile; the table header is 36 px, day rows 32 px, rows at
least 52 px; the slide-over is 480 px wide on desktop, the bottom sheet 88 % high.
- [ ] Error is drawn as the frame's alert (error soft background, circle-x, title, text, 28 px
outline "Try again"), the empty states as the frame's centred panel (44 px icon box, 15 px
semibold title, muted text up to 340 px).
- [ ] Not covered by screenshots: keyboard and focus order (Tab reaches Export CSV, the strip
cards, the search, the selects, the groups, a row; Enter opens the details; focus is trapped
in the slide-over and returns to the row on close; Escape closes it); hover (the row, the
native `title` tooltip), the 240 ms slide-over and sheet motion and the skeleton pulse, all
stopped under reduced motion; the date-range popover.
## Manual verification
- **Real round trips against `task dev`** — Vitest only mocks `useApi`. As owner: perform an
article publish, an image upload and a failed login, then confirm they appear newest first with
the right sentences, the shield marker on the failed login, the Succeeded and Failed badges, the
summary numbers and the sparkline bars; apply each filter and each card; page through with size
25, 50 and 100; open the details across a page boundary.
- **Real CSV** — click "Export CSV" with and without filters and open `activity.csv`: columns
`when,actor,action,target,outcome,source,ip`, the filtered rows only, no page or size applied,
neutralised cells that start with `=`, `+`, `-` or `@`; confirm the browser download prompt and
the two toasts.
- **Admin session** — sign in as an admin: the same full view as the owner.
- **Time zones** — set the OS time zone to UTC-8 and UTC+9, reload, and confirm the day
separators, the clock times, the "(your time)" line and the UTC line are consistent.
- **Regular user** — no real account can reach "My activity" today; confirm by a mocked
`/v1/auth/me` in the browser (or a future role) that the view has no strip, no Person filter and
no export, and that the server answers 403 for `actor=user:2` and for the summary and export.
- **API key** — calling `GET /v1/activity` with an API key answers 403 "This action needs a real
session." and the SPA never does it.
- **"Today", "Yesterday" and relative times** — confirm them against the real clock, since
visual:check skips every state that shows them.
- **French** — switch the language and read every state, the sentences and the toasts.
- **Real screen readers and zoom** — row selection, the sparkline buttons (labelled by the card
text), the sheet focus trap.
+14
View File
@@ -26,6 +26,7 @@ type Settings struct {
RetentionDays int `json:"activity_retention_days"` RetentionDays int `json:"activity_retention_days"`
LastCleanupAt *time.Time `json:"activity_last_cleanup_at"` LastCleanupAt *time.Time `json:"activity_last_cleanup_at"`
LastCleanupRemoved int `json:"activity_last_cleanup_removed"` LastCleanupRemoved int `json:"activity_last_cleanup_removed"`
NextCleanupAt *time.Time `json:"activity_next_cleanup_at"`
} }
func DefaultSettings() Settings { func DefaultSettings() Settings {
@@ -40,6 +41,19 @@ func (s Settings) CleanupDue(now time.Time) bool {
return s.LastCleanupAt == nil || s.LastCleanupAt.Before(todayRun) return s.LastCleanupAt == nil || s.LastCleanupAt.Before(todayRun)
} }
func (s Settings) WithNextCleanup(now time.Time) Settings {
s.NextCleanupAt = nil
if s.RetentionDays == ForeverRetentionDays {
return s
}
next := time.Date(now.Year(), now.Month(), now.Day(), CleanupHour, 0, 0, 0, now.Location())
if !next.After(now) {
next = time.Date(now.Year(), now.Month(), now.Day()+1, CleanupHour, 0, 0, 0, now.Location())
}
s.NextCleanupAt = &next
return s
}
func ClampCutoff(now time.Time, cutoff time.Time) time.Time { func ClampCutoff(now time.Time, cutoff time.Time) time.Time {
newest := now.Add(-KeepRecentHours * time.Hour) newest := now.Add(-KeepRecentHours * time.Hour)
if cutoff.After(newest) { if cutoff.After(newest) {
@@ -3,27 +3,34 @@ package useCases
import ( import (
"RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/audit"
"context" "context"
"time"
) )
type GetActivityRetentionUseCase struct { type GetActivityRetentionUseCase struct {
settings audit.SettingsRepository settings audit.SettingsRepository
now func() time.Time
} }
func NewGetActivityRetentionUseCase(settings audit.SettingsRepository) *GetActivityRetentionUseCase { func NewGetActivityRetentionUseCase(settings audit.SettingsRepository, now func() time.Time) *GetActivityRetentionUseCase {
return &GetActivityRetentionUseCase{settings} return &GetActivityRetentionUseCase{settings, now}
} }
func (g *GetActivityRetentionUseCase) GetActivityRetention() (audit.Settings, error) { func (g *GetActivityRetentionUseCase) GetActivityRetention() (audit.Settings, error) {
return g.settings.Get() current, err := g.settings.Get()
if err != nil {
return audit.Settings{}, err
}
return current.WithNextCleanup(g.now()), nil
} }
type UpdateActivityRetentionUseCase struct { type UpdateActivityRetentionUseCase struct {
settings audit.SettingsRepository settings audit.SettingsRepository
recorder audit.Recorder recorder audit.Recorder
now func() time.Time
} }
func NewUpdateActivityRetentionUseCase(settings audit.SettingsRepository, recorder audit.Recorder) *UpdateActivityRetentionUseCase { func NewUpdateActivityRetentionUseCase(settings audit.SettingsRepository, recorder audit.Recorder, now func() time.Time) *UpdateActivityRetentionUseCase {
return &UpdateActivityRetentionUseCase{settings, recorder} return &UpdateActivityRetentionUseCase{settings, recorder, now}
} }
func (u *UpdateActivityRetentionUseCase) UpdateActivityRetention(ctx context.Context, days int) (audit.Settings, error) { func (u *UpdateActivityRetentionUseCase) UpdateActivityRetention(ctx context.Context, days int) (audit.Settings, error) {
@@ -35,7 +42,7 @@ func (u *UpdateActivityRetentionUseCase) UpdateActivityRetention(ctx context.Con
return audit.Settings{}, err return audit.Settings{}, err
} }
if current.RetentionDays == days { if current.RetentionDays == days {
return current, nil return current.WithNextCleanup(u.now()), nil
} }
if err := u.settings.SaveRetention(days); err != nil { if err := u.settings.SaveRetention(days); err != nil {
return audit.Settings{}, err return audit.Settings{}, err
@@ -46,5 +53,5 @@ func (u *UpdateActivityRetentionUseCase) UpdateActivityRetention(ctx context.Con
u.recorder.Record(ctx, event) u.recorder.Record(ctx, event)
current.RetentionDays = days current.RetentionDays = days
return current, nil return current.WithNextCleanup(u.now()), nil
} }
@@ -16,7 +16,7 @@ const (
securityOnly = "true" securityOnly = "true"
) )
var activityPageSizes = []int{25, 50, 100} var activityPageSizes = []int{5, 25, 50, 100}
type ActivityQuery struct { type ActivityQuery struct {
Q string Q string
+3 -3
View File
@@ -81,8 +81,8 @@ func newActivityHTTPEnv(t *testing.T, attempts int) *activityHTTPEnv {
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(e.keys, e.users, h.clock, h.recorder), RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(e.keys, e.users, h.clock, h.recorder),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(e.keys, e.generator, e.users, h.clock), AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(e.keys, e.generator, e.users, h.clock),
GetSettingsUseCase: useCases.NewGetSettingsUseCase("v-test"), GetSettingsUseCase: useCases.NewGetSettingsUseCase("v-test"),
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(h.settings), GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(h.settings, h.clock),
UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder), UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock),
ListActivityUseCase: useCases.NewListActivityUseCase(h.probe, e.users, e.articles, newMockImageRepo(), e.keys, h.clock), ListActivityUseCase: useCases.NewListActivityUseCase(h.probe, e.users, e.articles, newMockImageRepo(), e.keys, h.clock),
GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(h.probe, h.clock), GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(h.probe, h.clock),
ExportActivityUseCase: useCases.NewExportActivityUseCase(h.probe, h.clock), ExportActivityUseCase: useCases.NewExportActivityUseCase(h.probe, h.clock),
@@ -670,7 +670,7 @@ func TestActivityLogBackendRetentionSetting(t *testing.T) {
rec := e.get("/v1/settings", asUser(e, activityEditorID)) rec := e.get("/v1/settings", asUser(e, activityEditorID))
assertJSONEqual(t, `{"version":"v-test","activity_retention_days":90,"activity_last_cleanup_at":null,"activity_last_cleanup_removed":0}`, rec.Body.String()) assertJSONEqual(t, `{"version":"v-test","activity_retention_days":90,"activity_last_cleanup_at":null,"activity_last_cleanup_removed":0,"activity_next_cleanup_at":"2026-10-07T03:00:00Z"}`, rec.Body.String())
}) })
t.Run("The last cleanup is reported", func(t *testing.T) { t.Run("The last cleanup is reported", func(t *testing.T) {
+255
View File
@@ -0,0 +1,255 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"testing"
"time"
)
var settingsTestZone = time.FixedZone("server", 2*60*60)
func settingsTestClock() time.Time {
return time.Date(2026, time.October, 6, 10, 0, 0, 0, settingsTestZone)
}
func TestSettingsNextCleanupDomain(t *testing.T) {
cases := []struct {
name string
days int
now time.Time
next string
}{
{"before 03:00 today", 90, time.Date(2026, 10, 6, 2, 59, 59, 0, settingsTestZone), "2026-10-06T03:00:00+02:00"},
{"mid day", 90, time.Date(2026, 10, 6, 10, 0, 0, 0, settingsTestZone), "2026-10-07T03:00:00+02:00"},
{"exactly 03:00 is strictly after", 90, time.Date(2026, 10, 6, 3, 0, 0, 0, settingsTestZone), "2026-10-07T03:00:00+02:00"},
{"year end", 30, time.Date(2026, 12, 31, 23, 30, 0, 0, settingsTestZone), "2027-01-01T03:00:00+02:00"},
{"365 days", 365, time.Date(2026, 10, 6, 10, 0, 0, 0, settingsTestZone), "2026-10-07T03:00:00+02:00"},
{"forever", 0, time.Date(2026, 10, 6, 10, 0, 0, 0, settingsTestZone), ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := audit.Settings{RetentionDays: c.days}.WithNextCleanup(c.now).NextCleanupAt
if c.next == "" {
if got != nil {
t.Fatalf("expected nil, got %v", got)
}
return
}
if got == nil || got.Format(time.RFC3339) != c.next {
t.Fatalf("expected %s, got %v", c.next, got)
}
})
}
}
func TestSettingsNextCleanupUseCases(t *testing.T) {
t.Run("Get returns the next cleanup from the clock or nil for Forever", func(t *testing.T) {
fake := newActivitySettingsFake()
get := useCases.NewGetActivityRetentionUseCase(fake, settingsTestClock)
current, err := get.GetActivityRetention()
if err != nil || current.NextCleanupAt == nil || current.NextCleanupAt.Format(time.RFC3339) != "2026-10-07T03:00:00+02:00" {
t.Fatalf("unexpected %+v, %v", current, err)
}
fake.settings.RetentionDays = 0
current, err = get.GetActivityRetention()
if err != nil || current.NextCleanupAt != nil {
t.Fatalf("expected nil next cleanup, got %+v, %v", current, err)
}
})
t.Run("Update returns the next cleanup of the new period", func(t *testing.T) {
fake := newActivitySettingsFake()
update := useCases.NewUpdateActivityRetentionUseCase(fake, discardRecorder, settingsTestClock)
forever, err := update.UpdateActivityRetention(testCtx, 0)
if err != nil || forever.RetentionDays != 0 || forever.NextCleanupAt != nil {
t.Fatalf("unexpected %+v, %v", forever, err)
}
back, err := update.UpdateActivityRetention(testCtx, 365)
if err != nil || back.NextCleanupAt == nil || back.NextCleanupAt.Format(time.RFC3339) != "2026-10-07T03:00:00+02:00" {
t.Fatalf("unexpected %+v, %v", back, err)
}
same, err := update.UpdateActivityRetention(testCtx, 365)
if err != nil || same.NextCleanupAt == nil {
t.Fatalf("unexpected %+v, %v", same, err)
}
})
t.Run("Update refuses an invalid period and saves nothing", func(t *testing.T) {
fake := newActivitySettingsFake()
_, err := useCases.NewUpdateActivityRetentionUseCase(fake, discardRecorder, settingsTestClock).UpdateActivityRetention(testCtx, 7)
if !errors.Is(err, audit.ErrInvalidRetention) || fake.retentionOK != 0 {
t.Fatalf("expected a refusal, got %v", err)
}
})
}
func TestSettingsNextCleanupHttp(t *testing.T) {
keys := func(t *testing.T, body string) map[string]any {
t.Helper()
var decoded map[string]any
if err := json.Unmarshal([]byte(body), &decoded); err != nil {
t.Fatalf("invalid json %q: %v", body, err)
}
return decoded
}
t.Run("GET keeps the existing fields and adds exactly one", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
e.settings.settings.LastCleanupAt = activityPtr(time.Date(2026, time.October, 6, 1, 0, 0, 0, time.UTC))
e.settings.settings.LastCleanupRemoved = 142
rec := e.get("/v1/settings", asUser(e, activityEditorID))
body := keys(t, rec.Body.String())
if rec.Code != http.StatusOK || len(body) != 5 || body["activity_next_cleanup_at"] != "2026-10-07T03:00:00Z" || body["activity_last_cleanup_removed"] != float64(142) {
t.Errorf("unexpected %d %v", rec.Code, body)
}
})
t.Run("GET is null for Forever and open to an admin", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
e.settings.settings.RetentionDays = 0
rec := e.get("/v1/settings", asUser(e, activityAdminID))
body := keys(t, rec.Body.String())
next, present := body["activity_next_cleanup_at"]
if rec.Code != http.StatusOK || !present || next != nil {
t.Errorf("unexpected %d %v", rec.Code, body)
}
})
t.Run("PATCH returns the next cleanup of the new period", func(t *testing.T) {
for _, c := range []struct {
from, to int
null bool
}{{90, 30, false}, {90, 0, true}, {0, 365, false}, {90, 90, false}} {
e := newActivityHTTPEnv(t, 1000)
e.settings.settings.RetentionDays = c.from
rec := e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_days": `+itoaSettings(c.to)+`}`, asUser(e, activityOwnerID))
body := keys(t, rec.Body.String())
next, present := body["activity_next_cleanup_at"]
if rec.Code != http.StatusOK || body["activity_retention_days"] != float64(c.to) || !present || (next == nil) != c.null {
t.Errorf("%d to %d: unexpected %d %v", c.from, c.to, rec.Code, body)
}
if !c.null && next != "2026-10-07T03:00:00Z" {
t.Errorf("%d to %d: unexpected next %v", c.from, c.to, next)
}
}
})
}
func TestSettingsNextCleanupQAFlow(t *testing.T) {
t.Run("GET next cleanup follows the server clock in a +02:00 zone", func(t *testing.T) {
cases := []struct {
days int
now time.Time
next any
}{
{90, time.Date(2026, 10, 6, 10, 0, 0, 0, settingsTestZone), "2026-10-07T03:00:00+02:00"},
{90, time.Date(2026, 10, 6, 2, 59, 59, 0, settingsTestZone), "2026-10-06T03:00:00+02:00"},
{90, time.Date(2026, 10, 6, 3, 0, 0, 0, settingsTestZone), "2026-10-07T03:00:00+02:00"},
{30, time.Date(2026, 12, 31, 23, 30, 0, 0, settingsTestZone), "2027-01-01T03:00:00+02:00"},
{365, time.Date(2026, 10, 6, 10, 0, 0, 0, settingsTestZone), "2026-10-07T03:00:00+02:00"},
{0, time.Date(2026, 10, 6, 10, 0, 0, 0, settingsTestZone), nil},
}
for _, c := range cases {
e := newActivityHTTPEnv(t, 1000)
e.now = c.now
e.settings.settings.RetentionDays = c.days
rec := e.get("/v1/settings", asUser(e, activityOwnerID))
var body map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &body)
next, present := body["activity_next_cleanup_at"]
if rec.Code != http.StatusOK || !present || next != c.next {
t.Errorf("%d at %s: unexpected %d %v", c.days, c.now, rec.Code, body)
}
}
})
t.Run("GET is open to owner, admin and editor and refused without a cookie", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
for _, id := range []uint32{activityOwnerID, activityAdminID, activityEditorID} {
if rec := e.get("/v1/settings", asUser(e, id)); rec.Code != http.StatusOK {
t.Errorf("user %d: expected 200, got %d", id, rec.Code)
}
}
if rec := e.get("/v1/settings"); rec.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rec.Code)
}
})
t.Run("PATCH by an editor is refused and changes nothing", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
rec := e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_days": 30}`, asUser(e, activityEditorID))
assertPlain(t, rec, http.StatusForbidden, apperr.OwnerRequiredErrorMessage)
if e.settings.retentionOK != 0 || len(e.all(t)) != 0 {
t.Error("the editor changed something")
}
})
t.Run("PATCH with the saved value saves nothing and records nothing", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
rec := e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_days": 90}`, asUser(e, activityOwnerID))
if rec.Code != http.StatusOK || e.settings.retentionOK != 0 || len(e.all(t)) != 0 {
t.Errorf("unexpected %d, saves %d, events %d", rec.Code, e.settings.retentionOK, len(e.all(t)))
}
})
t.Run("A failing settings repository answers 500", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
e.settings.getErr = errors.New("database is down")
assertPlain(t, e.get("/v1/settings", asUser(e, activityOwnerID)), http.StatusInternalServerError, "Internal server error")
assertPlain(t, e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_days": 30}`, asUser(e, activityOwnerID)), http.StatusInternalServerError, "Internal server error")
})
t.Run("The next cleanup stays at 03:00 local on a DST change day", func(t *testing.T) {
paris, err := time.LoadLocation("Europe/Paris")
if err != nil {
t.Skip("tzdata unavailable")
}
for _, c := range []struct {
now time.Time
next string
}{
{time.Date(2026, 10, 24, 10, 0, 0, 0, paris), "2026-10-25T03:00:00+01:00"},
{time.Date(2026, 10, 25, 1, 0, 0, 0, paris), "2026-10-25T03:00:00+01:00"},
{time.Date(2026, 10, 25, 10, 0, 0, 0, paris), "2026-10-26T03:00:00+01:00"},
{time.Date(2026, 3, 28, 10, 0, 0, 0, paris), "2026-03-29T03:00:00+02:00"},
} {
got := audit.Settings{RetentionDays: 90}.WithNextCleanup(c.now).NextCleanupAt
if got == nil || got.Format(time.RFC3339) != c.next || got.Hour() != audit.CleanupHour {
t.Errorf("from %s expected %s, got %v", c.now, c.next, got)
}
}
})
}
func itoaSettings(n int) string {
switch n {
case 0:
return "0"
case 30:
return "30"
case 90:
return "90"
}
return "365"
}
+57
View File
@@ -0,0 +1,57 @@
package test
import (
"testing"
"time"
)
func TestActivityRecentPageSize(t *testing.T) {
const homeCardQuery = "/v1/activity?size=5"
t.Run("The query the Home card sends returns the first five rows", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
var newest []uint64
for minute := 1; minute <= 8; minute++ {
event := e.seedBy(t, userActor(activityOwnerID, "Marta Alves"), time.Duration(minute)*time.Minute)
if minute <= 5 {
newest = append(newest, event.ID)
}
}
list := decodeActivityList(t, e.get(homeCardQuery, asUser(e, activityOwnerID)))
if list.Size != 5 || list.Page != 1 || list.Total != 8 || len(list.Items) != 5 {
t.Fatalf("unexpected page %d/%d total %d with %d items", list.Page, list.Size, list.Total, len(list.Items))
}
if !sameIDs(list.ids(), newest) {
t.Errorf("expected %v, got %v", newest, list.ids())
}
})
t.Run("A non-owner at size 5 only gets their own events", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
var own []uint64
for minute := 1; minute <= 7; minute++ {
event := e.seedBy(t, userActor(activityEditorID, "ed"), time.Duration(minute)*time.Minute)
if minute <= 5 {
own = append(own, event.ID)
}
}
for minute := 1; minute <= 3; minute++ {
e.seedBy(t, userActor(activityOwnerID, "Marta Alves"), time.Duration(minute)*time.Second)
}
list := decodeActivityList(t, e.get(homeCardQuery, asUser(e, activityEditorID)))
if list.Size != 5 || list.Total != 7 || !sameIDs(list.ids(), own) {
t.Errorf("expected own %v of 7, got %v of %d (size %d)", own, list.ids(), list.Total, list.Size)
}
})
t.Run("Sizes other than 5, 25, 50 and 100 stay refused", func(t *testing.T) {
for _, size := range []string{"4", "6", "10", "200"} {
e := newActivityHTTPEnv(t, 1000)
assertPlain(t, e.get("/v1/activity?size="+size, asUser(e, activityOwnerID)), 400, activityMismatch)
}
})
}
+7 -7
View File
@@ -18,7 +18,7 @@ func TestActivityRetentionUseCases(t *testing.T) {
t.Run("The default is 90 days with no cleanup yet", func(t *testing.T) { t.Run("The default is 90 days with no cleanup yet", func(t *testing.T) {
h := newActivityHarness(t) h := newActivityHarness(t)
settings, err := useCases.NewGetActivityRetentionUseCase(h.settings).GetActivityRetention() settings, err := useCases.NewGetActivityRetentionUseCase(h.settings, h.clock).GetActivityRetention()
if err != nil || settings.RetentionDays != 90 || settings.LastCleanupAt != nil || settings.LastCleanupRemoved != 0 { if err != nil || settings.RetentionDays != 90 || settings.LastCleanupAt != nil || settings.LastCleanupRemoved != 0 {
t.Errorf("unexpected default %+v, %v", settings, err) t.Errorf("unexpected default %+v, %v", settings, err)
@@ -29,7 +29,7 @@ func TestActivityRetentionUseCases(t *testing.T) {
for _, days := range []int{30, 90, 365, 0} { for _, days := range []int{30, 90, 365, 0} {
h := newActivityHarness(t) h := newActivityHarness(t)
settings, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder).UpdateActivityRetention(ownerCtx(), days) settings, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), days)
if err != nil || settings.RetentionDays != days || h.settings.settings.RetentionDays != days { if err != nil || settings.RetentionDays != days || h.settings.settings.RetentionDays != days {
t.Errorf("%d: unexpected result %+v, %v", days, settings, err) t.Errorf("%d: unexpected result %+v, %v", days, settings, err)
@@ -41,7 +41,7 @@ func TestActivityRetentionUseCases(t *testing.T) {
for _, days := range []int{7, -1, 366} { for _, days := range []int{7, -1, 366} {
h := newActivityHarness(t) h := newActivityHarness(t)
_, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder).UpdateActivityRetention(ownerCtx(), days) _, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), days)
if !errors.Is(err, audit.ErrInvalidRetention) || h.settings.settings.RetentionDays != 90 || len(h.all(t)) != 0 { if !errors.Is(err, audit.ErrInvalidRetention) || h.settings.settings.RetentionDays != 90 || len(h.all(t)) != 0 {
t.Errorf("%d: expected a refusal, got %v", days, err) t.Errorf("%d: expected a refusal, got %v", days, err)
@@ -52,7 +52,7 @@ func TestActivityRetentionUseCases(t *testing.T) {
t.Run("A change is recorded as a system event by the owner", func(t *testing.T) { t.Run("A change is recorded as a system event by the owner", func(t *testing.T) {
h := newActivityHarness(t) h := newActivityHarness(t)
_, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder).UpdateActivityRetention(ownerCtx(), 30) _, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), 30)
events := h.all(t) events := h.all(t)
if err != nil || len(events) != 1 { if err != nil || len(events) != 1 {
@@ -70,7 +70,7 @@ func TestActivityRetentionUseCases(t *testing.T) {
t.Run("Setting the same value records nothing", func(t *testing.T) { t.Run("Setting the same value records nothing", func(t *testing.T) {
h := newActivityHarness(t) h := newActivityHarness(t)
settings, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder).UpdateActivityRetention(ownerCtx(), 90) settings, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), 90)
if err != nil || settings.RetentionDays != 90 || len(h.all(t)) != 0 || h.settings.retentionOK != 0 { if err != nil || settings.RetentionDays != 90 || len(h.all(t)) != 0 || h.settings.retentionOK != 0 {
t.Errorf("expected no event and no write, got %v", err) t.Errorf("expected no event and no write, got %v", err)
@@ -81,7 +81,7 @@ func TestActivityRetentionUseCases(t *testing.T) {
h := newActivityHarness(t) h := newActivityHarness(t)
h.settings.saveErr = errRepositoryFailure h.settings.saveErr = errRepositoryFailure
_, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder).UpdateActivityRetention(ownerCtx(), 30) _, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), 30)
if !errors.Is(err, errRepositoryFailure) || len(h.all(t)) != 0 { if !errors.Is(err, errRepositoryFailure) || len(h.all(t)) != 0 {
t.Errorf("expected the failure and no event, got %v", err) t.Errorf("expected the failure and no event, got %v", err)
@@ -254,7 +254,7 @@ func TestActivityCleanupUseCase(t *testing.T) {
h.now = now h.now = now
h.seed(t, activityUserEvent(1, "a", now.Add(-40*24*time.Hour))) h.seed(t, activityUserEvent(1, "a", now.Add(-40*24*time.Hour)))
_, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder).UpdateActivityRetention(activityOwnerCtx(), 30) _, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock).UpdateActivityRetention(activityOwnerCtx(), 30)
if err != nil || len(h.all(t)) != 2 { if err != nil || len(h.all(t)) != 2 {
t.Fatalf("the change itself must remove nothing, got %d events, %v", len(h.all(t)), err) t.Fatalf("the change itself must remove nothing, got %d events, %v", len(h.all(t)), err)
} }
+7 -1
View File
@@ -67,6 +67,7 @@ func (p *activityProbeRepo) DeleteOlderThan(cutoff time.Time) (int, error) {
type activitySettingsFake struct { type activitySettingsFake struct {
settings audit.Settings settings audit.Settings
saveErr error saveErr error
getErr error
retentionOK int retentionOK int
cleanupOK int cleanupOK int
} }
@@ -77,7 +78,12 @@ func newActivitySettingsFake() *activitySettingsFake {
return &activitySettingsFake{settings: audit.DefaultSettings()} return &activitySettingsFake{settings: audit.DefaultSettings()}
} }
func (f *activitySettingsFake) Get() (audit.Settings, error) { return f.settings, nil } func (f *activitySettingsFake) Get() (audit.Settings, error) {
if f.getErr != nil {
return audit.Settings{}, f.getErr
}
return f.settings, nil
}
func (f *activitySettingsFake) SaveRetention(days int) error { func (f *activitySettingsFake) SaveRetention(days int) error {
if f.saveErr != nil { if f.saveErr != nil {
+1 -1
View File
@@ -133,7 +133,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
RequestRestartUseCase: useCases.NewRequestRestartUseCase(env.updates, restartJobs, &fakeHelperDetector{installed: true}, &fakeResultStore{}, &fakeRestartRequestStore{}, apiKeyRunningVersion, clock, recorder), RequestRestartUseCase: useCases.NewRequestRestartUseCase(env.updates, restartJobs, &fakeHelperDetector{installed: true}, &fakeResultStore{}, &fakeRestartRequestStore{}, apiKeyRunningVersion, clock, recorder),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles), GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
GetSettingsUseCase: useCases.NewGetSettingsUseCase(apiKeyRunningVersion), GetSettingsUseCase: useCases.NewGetSettingsUseCase(apiKeyRunningVersion),
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(newActivitySettingsFake()), GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(newActivitySettingsFake(), time.Now),
GetUpdateStatusUseCase: newUpdateStatusUseCase(env.updates, &fakeJobStore{}, &fakeHelperDetector{}, apiKeyRunningVersion), GetUpdateStatusUseCase: newUpdateStatusUseCase(env.updates, &fakeJobStore{}, &fakeHelperDetector{}, apiKeyRunningVersion),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(env.feed, env.updates, apiKeyRunningVersion, clock, recorder), CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(env.feed, env.updates, apiKeyRunningVersion, clock, recorder),
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(env.keys, env.generator, clock, recorder), CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(env.keys, env.generator, clock, recorder),
+3 -3
View File
@@ -19,8 +19,8 @@ func newSettingsRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth, version s
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
settingsHandler := settings.NewHandler( settingsHandler := settings.NewHandler(
useCases.NewGetSettingsUseCase(version), useCases.NewGetSettingsUseCase(version),
useCases.NewGetActivityRetentionUseCase(newActivitySettingsFake()), useCases.NewGetActivityRetentionUseCase(newActivitySettingsFake(), settingsTestClock),
useCases.NewUpdateActivityRetentionUseCase(newActivitySettingsFake(), discardRecorder), useCases.NewUpdateActivityRetentionUseCase(newActivitySettingsFake(), discardRecorder, settingsTestClock),
) )
r := chi.NewRouter() r := chi.NewRouter()
@@ -35,7 +35,7 @@ func newSettingsRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth, version s
} }
func settingsBody(version string) string { func settingsBody(version string) string {
return `{"version":"` + version + `","activity_retention_days":90,"activity_last_cleanup_at":null,"activity_last_cleanup_removed":0}` return `{"version":"` + version + `","activity_retention_days":90,"activity_last_cleanup_at":null,"activity_last_cleanup_removed":0,"activity_next_cleanup_at":"2026-10-07T03:00:00+02:00"}`
} }
func TestSettingsHttp(t *testing.T) { func TestSettingsHttp(t *testing.T) {
+1
View File
@@ -5,6 +5,7 @@
"": { "": {
"name": "renewcms-frontend", "name": "renewcms-frontend",
"dependencies": { "dependencies": {
"@internationalized/date": "^3.12.4",
"@nuxt/ui": "^4.11.2", "@nuxt/ui": "^4.11.2",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@tiptap/core": "3.31.3", "@tiptap/core": "3.31.3",
+1
View File
@@ -16,6 +16,7 @@
"format": "prettier --write --experimental-cli src/" "format": "prettier --write --experimental-cli src/"
}, },
"dependencies": { "dependencies": {
"@internationalized/date": "^3.12.4",
"@nuxt/ui": "^4.11.2", "@nuxt/ui": "^4.11.2",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@tiptap/core": "3.31.3", "@tiptap/core": "3.31.3",
+13
View File
@@ -105,6 +105,18 @@
--text-xl--line-height: 28px; --text-xl--line-height: 28px;
--text-2xl: 1.75rem; --text-2xl: 1.75rem;
--text-2xl--line-height: 36px; --text-2xl--line-height: 36px;
--text-3xs: 0.625rem;
--text-3xs--line-height: 16px;
--text-2xs: 0.6875rem;
--text-2xs--line-height: 20px;
--text-md: 0.9375rem;
--text-md--line-height: 22px;
--text-lead: 1.125rem;
--text-lead--line-height: 26px;
--text-stat-sm: 1.375rem;
--text-stat-sm--line-height: 32px;
--text-stat: 1.5rem;
--text-stat--line-height: 32px;
--text-code: 0.75rem; --text-code: 0.75rem;
--text-code--line-height: 19px; --text-code--line-height: 19px;
@@ -116,6 +128,7 @@
--shadow-popover: 0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06); --shadow-popover: 0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06);
--shadow-modal: 0 16px 48px rgb(17 17 17 / 0.18); --shadow-modal: 0 16px 48px rgb(17 17 17 / 0.18);
--shadow-flag: inset 2px 0 0 var(--ui-warning);
--ease-standard: cubic-bezier(0.2, 0, 0, 1); --ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-enter: cubic-bezier(0, 0, 0, 1); --ease-enter: cubic-bezier(0, 0, 0, 1);
+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import ActivityAvatar from '@/components/ActivityAvatar.vue'
import ActivityBadge from '@/components/ActivityBadge.vue'
import { type ActivityActor } from '@/lib/activity'
const props = defineProps<{
actor: ActivityActor
}>()
const displayName = computed(() =>
props.actor.type === 'anonymous' ? t('Not signed in') : props.actor.name,
)
</script>
<template>
<span class="flex min-w-0 items-center gap-2" data-testid="activity-actor">
<ActivityAvatar :actor="actor" />
<span class="min-w-0 truncate font-medium text-highlighted">{{ displayName }}</span>
<ActivityBadge v-if="actor.type === 'api_key'" tone="neutral" :label="t('API key')" />
<ActivityBadge v-else-if="actor.type === 'system'" tone="neutral" :label="t('System')" />
</span>
</template>
+40
View File
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { computed } from 'vue'
import { initials, type ActivityActor } from '@/lib/activity'
const props = defineProps<{
actor: ActivityActor
}>()
const iconName = computed(() => {
switch (props.actor.type) {
case 'api_key':
return 'i-lucide-key-round'
case 'system':
return 'i-lucide-cog'
default:
return 'i-lucide-user-round-x'
}
})
</script>
<template>
<UAvatar
v-if="actor.type === 'user'"
aria-hidden="true"
:text="initials(actor.name)"
:ui="{
root: 'size-6 shrink-0 bg-(--ui-color-secondary-200) dark:bg-(--ui-color-secondary-300)',
fallback: 'pt-px text-3xs font-semibold text-(--ui-color-primary-900)',
}"
/>
<UAvatar
v-else
aria-hidden="true"
:icon="iconName"
:ui="{
root: 'size-6 shrink-0 border border-accented bg-elevated',
icon: 'size-3.5 text-toned',
}"
/>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { computed } from 'vue'
export type ActivityBadgeTone = 'ok' | 'fail' | 'security' | 'deleted' | 'neutral'
const TONES: Record<ActivityBadgeTone, { class: string; icon?: string }> = {
ok: { class: 'border border-accented bg-transparent text-toned', icon: 'i-lucide-check' },
fail: { class: 'bg-(--rc-error-soft) text-error', icon: 'i-lucide-circle-x' },
security: { class: 'bg-(--rc-warning-soft) text-warning', icon: 'i-lucide-shield-alert' },
deleted: {
class: 'border border-accented bg-transparent text-toned',
icon: 'i-lucide-trash-2',
},
neutral: { class: 'border border-accented bg-transparent text-toned' },
}
const props = defineProps<{
tone: ActivityBadgeTone
label: string
}>()
const config = computed(() => TONES[props.tone])
</script>
<template>
<UBadge
color="neutral"
variant="soft"
:label="label"
:icon="config.icon"
:class="config.class"
:ui="{ base: 'h-5 shrink-0 gap-1 px-1.5 whitespace-nowrap', leadingIcon: 'size-3' }"
/>
</template>
+122
View File
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import ActivityActor from '@/components/ActivityActor.vue'
import ActivityBadge from '@/components/ActivityBadge.vue'
import ActivitySentence from '@/components/ActivitySentence.vue'
import { ACTIVITY_SKELETON_CARDS } from '@/composables/useActivity'
import { categoryIcon, outcomeBadge, rowSurface, type ActivityItem } from '@/lib/activity'
import { fullLocalTime, groupByDay, whenLabel } from '@/lib/activityTime'
const SKELETON_SENTENCE_WIDTHS = ['w-9/10', 'w-3/4', 'w-17/20']
const props = defineProps<{
items: ActivityItem[]
loading: boolean
selectedId: number | null
showActor: boolean
}>()
const emit = defineEmits<{
open: [item: ActivityItem, opener: HTMLElement]
}>()
const groups = computed(() => groupByDay(props.items))
function open(item: ActivityItem, event: Event) {
emit('open', item, event.currentTarget as HTMLElement)
}
function cardClass(item: ActivityItem) {
return rowSurface(props.selectedId === item.id, item.security)
}
</script>
<template>
<div
:aria-busy="loading ? 'true' : undefined"
class="flex flex-col overflow-hidden border border-default bg-default"
>
<template v-if="loading">
<span role="status" class="sr-only">{{ t('Loading activity') }}</span>
<div
v-for="n in ACTIVITY_SKELETON_CARDS"
:key="n"
class="flex flex-col gap-2.5 border-t border-default px-4 py-3.5"
data-testid="activity-skeleton-card"
>
<div class="flex items-center gap-2">
<USkeleton class="size-6 shrink-0 rounded-none bg-accented" />
<USkeleton class="h-3 w-22.5 rounded-none bg-accented" />
<span class="flex-1" />
<USkeleton class="h-2.5 w-10 rounded-none bg-accented" />
</div>
<USkeleton
class="h-3.5 rounded-none bg-accented"
:class="SKELETON_SENTENCE_WIDTHS[(n - 1) % 3]"
/>
<USkeleton class="h-5 w-17.5 rounded-none bg-accented" />
</div>
</template>
<template v-else>
<template v-for="group in groups" :key="group.key">
<div
data-testid="activity-day"
class="flex h-8 shrink-0 items-center border-t border-default bg-muted px-4 text-xs font-semibold text-highlighted"
>
{{ group.label }}
</div>
<div
v-for="item in group.items"
:key="item.id"
tabindex="0"
role="button"
data-testid="activity-card"
:data-activity-id="item.id"
:aria-pressed="selectedId === item.id"
:class="cardClass(item)"
class="flex min-h-11 cursor-pointer flex-col gap-2 border-t border-default px-4 py-3 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
@click="open(item, $event)"
@keydown.enter.self.prevent="open(item, $event)"
>
<div class="flex min-w-0 items-center gap-2">
<ActivityActor v-if="showActor" :actor="item.actor" class="flex-1" />
<span v-else class="flex-1" />
<span
data-testid="activity-when"
:title="fullLocalTime(item.created_at)"
class="text-xs/5 whitespace-nowrap text-muted"
>{{ whenLabel(item.created_at) }}</span
>
</div>
<div class="flex items-start gap-2">
<UIcon
:name="categoryIcon(item.category)"
class="mt-0.5 size-4 shrink-0 text-muted"
/>
<span
class="line-clamp-3 min-w-0 flex-1 text-md wrap-anywhere text-default"
>
<ActivitySentence :item="item" />
</span>
</div>
<div class="flex flex-wrap items-center gap-1.5">
<ActivityBadge v-bind="outcomeBadge(item.outcome)" />
<ActivityBadge
v-if="item.security"
tone="security"
:label="t('Security')"
/>
<ActivityBadge
v-if="item.target && !item.target.exists"
tone="deleted"
:label="t('Target deleted')"
/>
</div>
</div>
</template>
</template>
</div>
</template>
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import { ACTIVITY_CATEGORIES, categoryLabel, type ActivityCategory } from '@/lib/activity'
defineProps<{
category: ActivityCategory | ''
}>()
const emit = defineEmits<{
select: [category: ActivityCategory]
}>()
const items = computed(() =>
ACTIVITY_CATEGORIES.map((category) => ({
label: categoryLabel(category),
value: category,
})),
)
</script>
<template>
<USelectMenu
:model-value="category || undefined"
:items="items"
value-key="value"
icon="i-lucide-layout-list"
:placeholder="t('All categories')"
:search-input="false"
:aria-label="t('Category')"
color="neutral"
variant="outline"
class="w-full md:w-44"
:ui="{
base: 'h-11 rounded-none border border-accented bg-default ps-8 pe-8 text-sm ring-0 md:h-8',
leadingIcon: 'size-3.5 text-muted',
trailingIcon: 'size-3.5 text-muted',
placeholder: 'text-muted',
content: 'min-w-48',
}"
@update:model-value="emit('select', $event as ActivityCategory)"
/>
</template>
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { t } from '@/i18n'
import type { ActivityChip, ChipKey } from '@/lib/activity'
defineProps<{
chips: ActivityChip[]
}>()
const emit = defineEmits<{
remove: [key: ChipKey]
clear: []
}>()
</script>
<template>
<div
v-if="chips.length > 0"
data-testid="activity-chips"
class="flex flex-wrap items-center gap-2"
>
<UBadge
v-for="chip in chips"
:key="chip.key"
color="neutral"
variant="subtle"
data-testid="activity-chip"
:ui="{
base: 'h-8 gap-1 border border-default bg-elevated py-0 ps-2 pe-1 text-xs font-medium text-highlighted ring-0 md:h-6',
}"
>
{{ chip.label }}
<template #trailing>
<UButton
color="neutral"
variant="ghost"
icon="i-lucide-x"
:aria-label="t('Remove filter {0}', [chip.label])"
class="size-5 justify-center p-0 text-muted"
:ui="{ leadingIcon: 'size-3' }"
@click="emit('remove', chip.key)"
/>
</template>
</UBadge>
<UButton
color="neutral"
variant="link"
class="h-11 px-1.5 text-xs font-medium text-highlighted underline underline-offset-3 md:h-6"
@click="emit('clear')"
>
{{ t('Clear all') }}
</UButton>
</div>
</template>
+164
View File
@@ -0,0 +1,164 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref, watch } from 'vue'
import { CalendarDate, parseDate } from '@internationalized/date'
import { RANGE_KEYS, rangeLabel, type RangeKey } from '@/lib/activity'
import { SEGMENT_GROUP, segmentClass } from '@/lib/segmentStyles'
interface RangeChange {
range: RangeKey
from: string
to: string
}
interface CalendarRange {
start?: CalendarDate
end?: CalendarDate
}
const props = defineProps<{
range: RangeKey
from: string
to: string
block?: boolean
}>()
const emit = defineEmits<{
change: [value: RangeChange]
}>()
const popoverOpen = ref(false)
const customPending = ref(false)
const draftFrom = ref(props.from)
const draftTo = ref(props.to)
const presets = RANGE_KEYS.filter((key) => key !== 'custom')
const customSelected = computed(
() => props.range === 'custom' || popoverOpen.value || customPending.value,
)
const showFields = computed(() => props.block && customSelected.value)
function parse(value: string) {
try {
return parseDate(value)
} catch {
return undefined
}
}
const calendarValue = computed<CalendarRange>(() => ({
start: parse(props.from),
end: parse(props.to),
}))
watch(
() => [props.from, props.to],
([from, to]) => {
draftFrom.value = from ?? ''
draftTo.value = to ?? ''
},
)
function choose(range: RangeKey) {
customPending.value = false
emit('change', { range, from: '', to: '' })
}
function chooseCustom() {
if (props.block) customPending.value = true
}
function applyCustom(from: string, to: string) {
customPending.value = false
emit(
'change',
from <= to ? { range: 'custom', from, to } : { range: 'custom', from: to, to: from },
)
}
function onCalendar(value: CalendarRange | null) {
if (!value?.start || !value.end) return
popoverOpen.value = false
applyCustom(value.start.toString(), value.end.toString())
}
function onField(side: 'from' | 'to', value: string | number | null | undefined) {
const next = typeof value === 'string' ? value : ''
if (side === 'from') draftFrom.value = next
else draftTo.value = next
if (draftFrom.value && draftTo.value) applyCustom(draftFrom.value, draftTo.value)
}
</script>
<template>
<div class="flex flex-col gap-2" :class="block ? 'w-full' : ''">
<div role="group" :aria-label="t('Dates')" :class="[SEGMENT_GROUP, block ? 'w-full' : '']">
<UButton
v-for="key in presets"
:key="key"
color="neutral"
:variant="range === key && !customSelected ? 'soft' : 'outline'"
:aria-pressed="range === key && !customSelected"
:class="segmentClass(range === key && !customSelected, block)"
@click="choose(key)"
>
{{ rangeLabel(key) }}
</UButton>
<UButton
v-if="block"
color="neutral"
:variant="customSelected ? 'soft' : 'outline'"
icon="i-lucide-calendar"
:aria-pressed="customSelected"
:class="segmentClass(customSelected, block)"
:ui="{ leadingIcon: 'size-3.5' }"
@click="chooseCustom"
>
{{ rangeLabel('custom') }}
</UButton>
<UPopover v-else v-model:open="popoverOpen" :content="{ align: 'end' }">
<UButton
color="neutral"
:variant="customSelected ? 'soft' : 'outline'"
icon="i-lucide-calendar"
:aria-pressed="customSelected"
:class="segmentClass(customSelected)"
:ui="{ leadingIcon: 'size-3.5' }"
>
{{ rangeLabel('custom') }}
</UButton>
<template #content>
<UCalendar
range
:model-value="calendarValue"
:number-of-months="1"
class="p-2"
@update:model-value="onCalendar($event as CalendarRange | null)"
/>
</template>
</UPopover>
</div>
<div v-if="showFields" class="flex items-center gap-2">
<UInput
type="date"
icon="i-lucide-calendar"
:model-value="draftFrom"
:aria-label="t('Start date')"
class="min-w-0 flex-1"
:ui="{ base: 'h-11 w-full min-w-0 text-lg' }"
@update:model-value="onField('from', $event)"
/>
<UInput
type="date"
icon="i-lucide-calendar"
:model-value="draftTo"
:aria-label="t('End date')"
class="min-w-0 flex-1"
:ui="{ base: 'h-11 w-full min-w-0 text-lg' }"
@update:model-value="onField('to', $event)"
/>
</div>
</div>
</template>
+224
View File
@@ -0,0 +1,224 @@
<script setup lang="ts">
import { t, formatNumber } from '@/i18n'
import { computed, ref, watch } from 'vue'
import ActivityActor from '@/components/ActivityActor.vue'
import ActivityBadge from '@/components/ActivityBadge.vue'
import ActivityDiff from '@/components/ActivityDiff.vue'
import ActivitySentence from '@/components/ActivitySentence.vue'
import {
categoryIcon,
categoryLabel,
outcomeBadge,
sourceLabel,
targetLink,
targetTypeLabel,
type ActivityItem,
} from '@/lib/activity'
import { fullLocalTime, fullUtcTime } from '@/lib/activityTime'
const props = defineProps<{
item: ActivityItem | null
position: number
total: number
canPrevious: boolean
canNext: boolean
isDesktop: boolean
}>()
const emit = defineEmits<{
previous: []
next: []
close: []
}>()
const retained = ref<ActivityItem | null>(props.item)
watch(
() => props.item,
(value) => {
if (value) retained.value = value
},
)
const current = computed(() => props.item ?? retained.value)
const link = computed(() => targetLink(current.value?.target ?? null))
const side = computed(() => (props.isDesktop ? 'right' : 'bottom'))
const positionOf = computed(() =>
t('{0} of {1}', [formatNumber(props.position), formatNumber(props.total)]),
)
const contentClass = computed(() =>
props.isDesktop
? 'w-full max-w-120'
: 'h-[88%] max-h-none before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented) pt-3',
)
const closeButton = computed(() => ({
color: 'neutral' as const,
variant: 'ghost' as const,
icon: 'i-lucide-x',
class: 'text-muted',
}))
function onOpenChange(isOpen: boolean) {
if (!isOpen) emit('close')
}
</script>
<template>
<USlideover
:open="item !== null"
:side="side"
:title="t('Activity details')"
:description="t('Activity details')"
:close="closeButton"
:ui="{
content: contentClass,
header: 'h-14 min-h-14 border-b border-default px-4 py-0 sm:px-4 md:px-5',
wrapper: 'min-w-0 flex-1 pe-10 md:pe-8',
title: 'flex items-baseline justify-between gap-2 text-lg font-semibold text-highlighted',
description: 'sr-only',
close: 'top-4.5 end-2 size-11 justify-center p-0 md:top-3.5 md:end-5 md:size-7',
body: 'flex-1 overflow-y-auto p-4 sm:p-4 md:p-5',
footer: 'flex gap-2 border-t border-default px-4 py-3 sm:px-4 md:px-5',
}"
@update:open="onOpenChange"
>
<template #title>
<span>{{ t('Activity details') }}</span>
<span v-if="!isDesktop" class="text-xs font-normal text-muted">{{ positionOf }}</span>
</template>
<template #body>
<div v-if="current" data-testid="activity-details" class="flex flex-col gap-5">
<div class="flex flex-col gap-2.5">
<p
data-testid="activity-details-sentence"
class="text-lead font-semibold wrap-anywhere text-highlighted"
>
<ActivitySentence :item="current" />
</p>
<div class="flex flex-wrap items-center gap-1.5">
<ActivityBadge v-bind="outcomeBadge(current.outcome)" />
<ActivityBadge
v-if="current.security"
tone="security"
:label="t('Security')"
/>
<span class="inline-flex items-center gap-1 text-xs text-muted">
<UIcon :name="categoryIcon(current.category)" class="size-3.5" />
{{ categoryLabel(current.category) }}
</span>
</div>
</div>
<UAlert
v-if="current.outcome === 'failed' && current.failure_reason"
role="alert"
color="error"
variant="soft"
icon="i-lucide-circle-x"
:title="t('Why it failed')"
:description="current.failure_reason"
/>
<dl class="grid grid-cols-[96px_minmax(0,1fr)] items-start gap-x-3 gap-y-2.5">
<dt class="text-muted">{{ t('When') }}</dt>
<dd class="m-0 flex flex-col gap-0.5">
<span class="text-highlighted">{{
t('{0} (your time)', [fullLocalTime(current.created_at)])
}}</span>
<span class="text-xs/5 text-muted">{{
t('{0} UTC', [fullUtcTime(current.created_at)])
}}</span>
</dd>
<dt class="text-muted">{{ t('Actor') }}</dt>
<dd class="m-0 min-w-0"><ActivityActor :actor="current.actor" /></dd>
<dt class="text-muted">{{ t('Source') }}</dt>
<dd class="m-0 text-highlighted">{{ sourceLabel(current.source) }}</dd>
<dt class="text-muted">{{ t('IP address') }}</dt>
<dd v-if="current.ip" class="m-0 font-mono text-xs/5 text-highlighted">
{{ current.ip }}
</dd>
<dd v-else class="m-0 text-muted">{{ t('Not applicable') }}</dd>
<dt class="text-muted">{{ t('Browser') }}</dt>
<dd v-if="current.user_agent" class="m-0 text-highlighted">
{{ current.user_agent }}
</dd>
<dd v-else class="m-0 text-muted">{{ t('Not applicable') }}</dd>
</dl>
<section
v-if="current.target"
class="flex flex-col gap-2"
data-testid="activity-target-section"
>
<h3 class="text-base font-semibold text-highlighted">{{ t('Target') }}</h3>
<div class="flex flex-col gap-1.5 border border-default px-3 py-2.5">
<div class="flex min-w-0 items-center gap-2">
<template v-if="!current.target.exists">
<span
class="min-w-0 flex-1 wrap-anywhere text-muted line-through"
>{{ current.target.name }}</span
>
<ActivityBadge tone="deleted" :label="t('Deleted')" />
</template>
<RouterLink
v-else-if="link"
:to="link"
class="flex min-w-0 flex-1 items-center gap-1 wrap-anywhere text-highlighted underline underline-offset-3"
>
{{ current.target.name }}
<UIcon
name="i-lucide-arrow-up-right"
class="size-3.5 shrink-0 text-muted"
/>
</RouterLink>
<span v-else class="min-w-0 flex-1 wrap-anywhere text-highlighted">{{
current.target.name
}}</span>
</div>
<span class="text-xs/5 text-muted">{{
`${targetTypeLabel(current.target.type)} · ${current.target.id}`
}}</span>
<span v-if="!current.target.exists" class="text-xs/5 text-toned">{{
t('This item was deleted. Its name is kept for reference.')
}}</span>
</div>
</section>
<section v-if="current.changes.length > 0" class="flex flex-col gap-2">
<h3 class="text-base font-semibold text-highlighted">{{ t('Changes') }}</h3>
<ActivityDiff :changes="current.changes" :is-desktop="isDesktop" />
</section>
</div>
</template>
<template #footer>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-chevron-up"
:disabled="!canPrevious"
class="h-11 justify-center border border-accented bg-default px-3 text-sm text-highlighted ring-0 max-md:flex-1 md:h-8"
@click="emit('previous')"
>
{{ t('Previous event') }}
</UButton>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-chevron-down"
:disabled="!canNext"
class="h-11 justify-center border border-accented bg-default px-3 text-sm text-highlighted ring-0 max-md:flex-1 md:h-8"
@click="emit('next')"
>
{{ t('Next event') }}
</UButton>
<span v-if="isDesktop" class="ml-auto self-center text-xs text-muted">{{
t('Event {0} of {1}', [formatNumber(position), formatNumber(total)])
}}</span>
</template>
</USlideover>
</template>
+113
View File
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { bodyDeltaLabel, changeValue, fieldLabel, type ActivityChange } from '@/lib/activity'
const props = defineProps<{
changes: ActivityChange[]
isDesktop: boolean
}>()
const expanded = ref<Record<number, boolean>>({})
const overflowing = ref<Record<number, boolean>>({})
const valueBoxes = new Map<number, HTMLElement[]>()
function register(index: number, element: unknown) {
if (!(element instanceof HTMLElement)) return
const boxes = valueBoxes.get(index) ?? []
if (!boxes.includes(element)) valueBoxes.set(index, [...boxes, element])
}
function measure() {
const next: Record<number, boolean> = { ...overflowing.value }
for (const [index, boxes] of valueBoxes) {
if (expanded.value[index]) continue
next[index] = boxes.some((box) => box.scrollHeight > box.clientHeight)
}
overflowing.value = next
}
function toggle(index: number) {
expanded.value = { ...expanded.value, [index]: !expanded.value[index] }
}
watch(
() => props.changes,
() => {
expanded.value = {}
overflowing.value = {}
valueBoxes.clear()
nextTick(measure)
},
)
onMounted(() => {
nextTick(measure)
window.addEventListener('resize', measure)
})
onBeforeUnmount(() => window.removeEventListener('resize', measure))
</script>
<template>
<div data-testid="activity-diff" class="flex flex-col border border-default">
<div
v-for="(change, index) in changes"
:key="index"
data-testid="activity-change"
class="flex flex-col gap-2 border-t border-muted p-3 first:border-t-0"
>
<span class="font-medium text-highlighted">{{ fieldLabel(change.field) }}</span>
<span
v-if="change.kind === 'body'"
data-testid="activity-body-change"
class="flex items-center gap-1.5 text-toned"
>
<UIcon name="i-lucide-file-pen-line" class="size-3.5 shrink-0" />
{{ bodyDeltaLabel(change.delta) }}
</span>
<template v-else>
<div
class="grid items-start gap-1.5 md:grid-cols-[minmax(0,1fr)_16px_minmax(0,1fr)] md:gap-2"
>
<div class="flex min-w-0 flex-col gap-0.5">
<span class="text-2xs text-muted">{{ t('Before') }}</span>
<span
:ref="(element) => register(index, element)"
data-testid="activity-before"
class="border border-default bg-elevated px-2.5 py-2 wrap-anywhere text-toned"
:class="expanded[index] ? '' : 'line-clamp-2'"
>{{ changeValue(change.field, change.before) }}</span
>
</div>
<UIcon
:name="isDesktop ? 'i-lucide-arrow-right' : 'i-lucide-arrow-down'"
class="size-3.5 shrink-0 justify-self-center text-muted md:mt-7.5"
/>
<div class="flex min-w-0 flex-col gap-0.5">
<span class="text-2xs text-muted">{{ t('After') }}</span>
<span
:ref="(element) => register(index, element)"
data-testid="activity-after"
class="border border-accented bg-default px-2.5 py-2 wrap-anywhere text-highlighted"
:class="expanded[index] ? '' : 'line-clamp-2'"
>{{ changeValue(change.field, change.after) }}</span
>
</div>
</div>
<UButton
v-if="overflowing[index]"
color="neutral"
variant="link"
class="min-h-8 self-start px-0 text-xs font-medium text-highlighted underline underline-offset-3 md:min-h-4"
@click="toggle(index)"
>
{{ expanded[index] ? t('Show less') : t('Show more') }}
</UButton>
</template>
</div>
</div>
</template>
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
defineProps<{
icon: string
title: string
description: string
actionLabel?: string
}>()
const emit = defineEmits<{ action: [] }>()
</script>
<template>
<UEmpty
variant="naked"
:icon="icon"
:title="title"
:description="description"
:ui="{
root: 'gap-3 border border-default bg-default px-6 py-14 sm:px-6 sm:py-14 lg:px-6 lg:py-14',
header: 'gap-3',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-md/5 font-semibold',
description: 'max-w-85 text-sm/5 text-muted text-pretty',
}"
>
<template v-if="actionLabel" #actions>
<UButton
color="neutral"
variant="outline"
class="h-11 border border-accented bg-default px-4 text-sm text-highlighted ring-0 md:h-8"
@click="emit('action')"
>
{{ actionLabel }}
</UButton>
</template>
</UEmpty>
</template>
+150
View File
@@ -0,0 +1,150 @@
<script setup lang="ts">
import { t } from '@/i18n'
import ActivityAvatar from '@/components/ActivityAvatar.vue'
import ActivityBadge from '@/components/ActivityBadge.vue'
import ActivitySentence from '@/components/ActivitySentence.vue'
import type { ActivityItem } from '@/lib/activity'
import { fullLocalTime, recentWhenLabel } from '@/lib/activityTime'
const SKELETON_ROWS = 5
const SKELETON_SENTENCE_WIDTHS = ['w-17/20', 'w-7/10', 'w-9/10', 'w-3/5', 'w-4/5']
defineProps<{
items: ActivityItem[]
loading: boolean
showSkeleton: boolean
error: boolean
own: boolean
}>()
defineEmits<{
retry: []
}>()
function metaLine(item: ActivityItem, own: boolean) {
const when = recentWhenLabel(item.created_at)
if (own) return when
const name = item.actor.type === 'anonymous' ? t('Not signed in') : item.actor.name
return `${name} · ${when}`
}
</script>
<template>
<UCard
data-testid="recent-activity-card"
:aria-busy="loading && showSkeleton ? 'true' : undefined"
:ui="{
root: 'min-w-0 rounded-none border border-default bg-default shadow-none ring-0 divide-y-0',
header: 'flex items-center gap-2 border-b border-default px-4 py-3 sm:px-4 sm:py-3',
body: 'flex flex-col px-4 pt-1 pb-1.5 sm:px-4 sm:pt-1 sm:pb-1.5',
}"
>
<template #header>
<h2 class="flex-1 text-base font-semibold text-highlighted">
{{ own ? t('Your recent activity') : t('Recent activity') }}
</h2>
<RouterLink
to="/activity"
class="flex items-center gap-1 font-medium text-highlighted underline underline-offset-3 max-lg:min-h-11 lg:min-h-5"
>
{{ t('View all') }}
<UIcon name="i-lucide-arrow-right" class="size-3.5" />
</RouterLink>
</template>
<template v-if="loading">
<template v-if="showSkeleton">
<div
v-for="n in SKELETON_ROWS"
:key="n"
data-testid="recent-activity-skeleton"
class="flex gap-2.5 py-2.5"
:class="n > 1 ? 'border-t border-muted' : ''"
>
<USkeleton class="size-6 shrink-0 rounded-none bg-accented" />
<div class="flex flex-1 flex-col gap-1.5">
<USkeleton
class="h-3 rounded-none bg-accented"
:class="SKELETON_SENTENCE_WIDTHS[n - 1]"
/>
<USkeleton class="h-2.5 w-7/20 rounded-none bg-accented" />
</div>
</div>
</template>
</template>
<UAlert
v-else-if="error"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t load recent activity')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
class="h-11 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 lg:h-7"
@click="$emit('retry')"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<div
v-else-if="items.length === 0"
data-testid="recent-activity-empty"
class="flex flex-col items-center gap-1.5 py-6 text-center"
>
<UIcon name="i-lucide-history" class="size-5 text-muted" />
<span class="font-semibold text-highlighted">{{ t('No activity yet') }}</span>
<span class="max-w-60 text-muted">
{{
own
? t('Your actions will appear here.')
: t('Actions on your site will appear here.')
}}
</span>
</div>
<ul v-else class="flex flex-col">
<li
v-for="(item, index) in items"
:key="item.id"
data-testid="recent-activity-row"
class="flex items-start gap-2.5 py-2.5"
:class="index > 0 ? 'border-t border-muted' : ''"
>
<ActivityAvatar :actor="item.actor" />
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span
class="line-clamp-2 text-default wrap-anywhere"
data-testid="recent-activity-sentence"
>
<ActivitySentence :item="item" />
</span>
<span
data-testid="recent-activity-meta"
:title="fullLocalTime(item.created_at)"
class="truncate text-xs/5 text-muted"
>
{{ metaLine(item, own) }}
</span>
</div>
<ActivityBadge v-if="item.outcome === 'failed'" tone="fail" :label="t('Failed')" />
<UIcon
v-else-if="item.security"
name="i-lucide-shield-alert"
data-testid="recent-activity-security"
class="size-3.5 shrink-0 text-warning"
/>
</li>
</ul>
</UCard>
</template>
+174
View File
@@ -0,0 +1,174 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import ActivityCategorySelect from '@/components/ActivityCategorySelect.vue'
import ActivityDatePresets from '@/components/ActivityDatePresets.vue'
import ActivityPersonSelect from '@/components/ActivityPersonSelect.vue'
import ActivitySegmented from '@/components/ActivitySegmented.vue'
import type { PersonOptions } from '@/composables/useActivity'
import { outcomeOptions, type ActivityFilters, type OutcomeFilter } from '@/lib/activity'
const SEARCH_DEBOUNCE_MS = 300
const props = defineProps<{
filters: ActivityFilters
people: PersonOptions | null
canFilterPeople: boolean
isDesktop: boolean
chipCount: number
}>()
const emit = defineEmits<{
'update:filters': [filters: ActivityFilters]
'open-people': []
'open-sheet': []
}>()
const searchText = ref(props.filters.q)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
const searchIconClass = computed(
() => `size-3.5 ${searchText.value ? 'text-highlighted' : 'text-muted'}`,
)
const outcomeChoices = computed(() => outcomeOptions())
function clearDebounce() {
if (debounceTimer !== null) {
clearTimeout(debounceTimer)
debounceTimer = null
}
}
function applySearch() {
clearDebounce()
const trimmed = searchText.value.trim()
if (trimmed !== props.filters.q) emit('update:filters', { ...props.filters, q: trimmed })
}
watch(
() => props.filters.q,
(value) => {
if (value !== searchText.value.trim()) {
clearDebounce()
searchText.value = value
}
},
)
watch(searchText, () => {
clearDebounce()
debounceTimer = setTimeout(applySearch, SEARCH_DEBOUNCE_MS)
})
function clearSearch() {
searchText.value = ''
applySearch()
}
function onActor(actor: string, label: string) {
emit('update:filters', { ...props.filters, actor, actorLabel: label })
}
function onOutcome(outcome: OutcomeFilter) {
emit('update:filters', { ...props.filters, outcome })
}
onBeforeUnmount(clearDebounce)
</script>
<template>
<div
v-if="isDesktop"
data-testid="activity-filter-bar"
class="flex flex-wrap items-center gap-2"
>
<UInput
v-model="searchText"
type="text"
icon="i-lucide-search"
:placeholder="t('Search activity')"
:aria-label="t('Search activity')"
class="w-60"
:ui="{ base: 'h-8 ps-8 text-sm', leadingIcon: searchIconClass }"
@keydown.enter="applySearch"
>
<template v-if="searchText" #trailing>
<UButton
color="neutral"
variant="link"
size="xs"
icon="i-lucide-x"
:aria-label="t('Clear search')"
class="text-muted"
@click="clearSearch"
/>
</template>
</UInput>
<ActivityPersonSelect
v-if="canFilterPeople"
:actor="filters.actor"
:people="people"
@select="onActor"
@open="emit('open-people')"
/>
<ActivityCategorySelect
:category="filters.category"
@select="emit('update:filters', { ...filters, category: $event })"
/>
<ActivitySegmented
:options="outcomeChoices"
:model-value="filters.outcome"
:label="t('Outcome')"
@update:model-value="onOutcome"
/>
<div class="min-w-0 flex-1" />
<ActivityDatePresets
:range="filters.range"
:from="filters.from"
:to="filters.to"
@change="emit('update:filters', { ...filters, ...$event })"
/>
</div>
<div v-else data-testid="activity-filter-bar" class="flex gap-2">
<UInput
v-model="searchText"
type="text"
icon="i-lucide-search"
:placeholder="t('Search activity')"
:aria-label="t('Search activity')"
class="min-w-0 flex-1"
:ui="{ base: 'h-11 ps-8 text-lg', leadingIcon: searchIconClass }"
@keydown.enter="applySearch"
>
<template v-if="searchText" #trailing>
<UButton
color="neutral"
variant="link"
size="xs"
icon="i-lucide-x"
:aria-label="t('Clear search')"
class="text-muted"
@click="clearSearch"
/>
</template>
</UInput>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-sliders-horizontal"
class="h-11 shrink-0 border border-accented bg-default px-3 text-sm text-highlighted ring-0"
@click="emit('open-sheet')"
>
{{ t('Filters') }}
<span
v-if="chipCount > 0"
data-testid="activity-filter-count"
class="inline-flex h-4.5 min-w-4.5 items-center justify-center bg-primary px-1.25 font-mono text-2xs font-medium text-(--ui-bg)"
>{{ chipCount }}</span
>
</UButton>
</div>
</template>
+115
View File
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { t, formatNumber } from '@/i18n'
import { computed } from 'vue'
import ActivityCategorySelect from '@/components/ActivityCategorySelect.vue'
import ActivityDatePresets from '@/components/ActivityDatePresets.vue'
import ActivityPersonSelect from '@/components/ActivityPersonSelect.vue'
import ActivitySegmented from '@/components/ActivitySegmented.vue'
import type { PersonOptions } from '@/composables/useActivity'
import { outcomeOptions, type ActivityFilters, type OutcomeFilter } from '@/lib/activity'
const props = defineProps<{
filters: ActivityFilters
people: PersonOptions | null
canFilterPeople: boolean
chipCount: number
total: number
}>()
const emit = defineEmits<{
'update:filters': [filters: ActivityFilters]
'open-people': []
clear: []
}>()
const open = defineModel<boolean>('open', { default: false })
const outcomeChoices = computed(() => outcomeOptions())
const showLabel = computed(() =>
props.total === 1 ? t('Show 1 result') : t('Show {0} results', [formatNumber(props.total)]),
)
function onActor(actor: string, label: string) {
emit('update:filters', { ...props.filters, actor, actorLabel: label })
}
function onOutcome(outcome: OutcomeFilter) {
emit('update:filters', { ...props.filters, outcome })
}
</script>
<template>
<USlideover
v-model:open="open"
side="bottom"
:title="t('Filters')"
:description="t('Filters')"
:ui="{
content:
'max-h-none before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented) pt-3',
header: 'h-14 min-h-14 border-b border-default px-4 py-0 sm:px-4',
title: 'text-lg font-semibold text-highlighted',
description: 'sr-only',
close: 'top-4.5 end-2 size-11 justify-center p-0',
body: 'p-4 sm:p-4',
footer: 'gap-2 border-t border-default px-4 pt-3 pb-4 sm:px-4',
}"
>
<template #body>
<div data-testid="activity-filters-sheet" class="flex flex-col gap-4">
<div v-if="canFilterPeople" class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">{{ t('Person') }}</span>
<ActivityPersonSelect
:actor="filters.actor"
:people="people"
@select="onActor"
@open="emit('open-people')"
/>
</div>
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">{{ t('Category') }}</span>
<ActivityCategorySelect
:category="filters.category"
@select="emit('update:filters', { ...filters, category: $event })"
/>
</div>
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">{{ t('Outcome') }}</span>
<ActivitySegmented
block
:options="outcomeChoices"
:model-value="filters.outcome"
:label="t('Outcome')"
@update:model-value="onOutcome"
/>
</div>
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">{{ t('Dates') }}</span>
<ActivityDatePresets
block
:range="filters.range"
:from="filters.from"
:to="filters.to"
@change="emit('update:filters', { ...filters, ...$event })"
/>
</div>
</div>
</template>
<template #footer>
<UButton
color="neutral"
variant="outline"
:disabled="chipCount === 0"
class="h-11 flex-1 justify-center border border-accented bg-default px-3 text-sm text-highlighted ring-0"
@click="emit('clear')"
>
{{ t('Clear all') }}
</UButton>
<UButton class="h-11 flex-1 justify-center px-3 text-sm" @click="open = false">
{{ showLabel }}
</UButton>
</template>
</USlideover>
</template>
+118
View File
@@ -0,0 +1,118 @@
<script setup lang="ts">
import { t, formatNumber } from '@/i18n'
import { computed } from 'vue'
import { PAGE_SIZES } from '@/lib/activity'
const props = defineProps<{
page: number
pageCount: number
total: number
size: number
from: number
to: number
isDesktop: boolean
}>()
const emit = defineEmits<{
'update:page': [page: number]
'update:size': [size: number]
}>()
const sizeItems = PAGE_SIZES.map((value) => ({ label: String(value), value }))
const range = computed(() => ({
from: formatNumber(props.from),
to: formatNumber(props.to),
total: formatNumber(props.total),
}))
const PAGER_BUTTON =
'size-11 justify-center border border-accented bg-default p-0 text-highlighted ring-0 md:size-8'
</script>
<template>
<div v-if="isDesktop" data-testid="activity-pager" class="flex shrink-0 items-center gap-4">
<span class="text-muted">{{
t('Showing {0}–{1} of {2}', [range.from, range.to, range.total])
}}</span>
<div class="flex-1" />
<div class="flex items-center gap-2 text-muted">
<span>{{ t('Rows per page') }}</span>
<USelectMenu
:model-value="size"
:items="sizeItems"
value-key="value"
:search-input="false"
:aria-label="t('Rows per page')"
color="neutral"
variant="outline"
class="w-20"
:ui="{
base: 'h-8 rounded-none border border-accented bg-default px-2.5 text-sm ring-0',
trailingIcon: 'size-3.5 text-muted',
}"
@update:model-value="emit('update:size', $event as number)"
/>
</div>
<UPagination
:page="page"
:total="total"
:items-per-page="size"
:sibling-count="1"
show-edges
color="neutral"
variant="outline"
active-color="primary"
active-variant="solid"
:ui="{
item: 'size-8 justify-center border border-accented ring-0',
prev: 'size-8 justify-center border border-accented ring-0',
next: 'size-8 justify-center border border-accented ring-0',
}"
@update:page="emit('update:page', $event)"
/>
</div>
<div v-else data-testid="activity-pager" class="flex shrink-0 items-center gap-2">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-chevron-left"
:disabled="page <= 1"
:aria-label="t('Previous page')"
:class="PAGER_BUTTON"
@click="emit('update:page', page - 1)"
/>
<div class="flex flex-1 flex-col items-center text-xs text-muted">
<span class="font-medium text-highlighted">{{
t('Page {0} of {1}', [formatNumber(page), formatNumber(pageCount)])
}}</span>
<span>{{ t('{0}–{1} of {2}', [range.from, range.to, range.total]) }}</span>
</div>
<USelectMenu
:model-value="size"
:items="sizeItems"
value-key="value"
:search-input="false"
:aria-label="t('Rows per page')"
color="neutral"
variant="outline"
class="w-24 shrink-0"
:ui="{
base: 'h-11 rounded-none border border-accented bg-default px-2.5 text-sm ring-0',
trailingIcon: 'size-3.5 text-muted',
}"
@update:model-value="emit('update:size', $event as number)"
/>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-chevron-right"
:disabled="page >= pageCount"
:aria-label="t('Next page')"
:class="PAGER_BUTTON"
@click="emit('update:page', page + 1)"
/>
</div>
</template>
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import type { PersonOptions } from '@/composables/useActivity'
const props = defineProps<{
actor: string
people: PersonOptions | null
}>()
const emit = defineEmits<{
select: [actor: string, label: string]
open: []
}>()
const groups = computed(() => {
const users = (props.people?.users ?? []).map((user) => ({
label: user.name,
value: `user:${user.id}`,
}))
const keys = (props.people?.keys ?? []).map((key) => ({
label: key.name,
value: `key:${key.id}`,
}))
return [
[{ type: 'label' as const, label: t('Users') }, ...users],
[{ type: 'label' as const, label: t('API keys') }, ...keys],
[{ label: t('System'), value: 'system' }],
]
})
function labelFor(value: string) {
if (value === 'system') return t('System')
const options = value.startsWith('user:') ? props.people?.users : props.people?.keys
const id = Number(value.slice(value.indexOf(':') + 1))
return options?.find((option) => option.id === id)?.name ?? value
}
function onSelect(value: string | undefined) {
if (value) emit('select', value, labelFor(value))
}
</script>
<template>
<USelectMenu
:model-value="actor || undefined"
:items="groups"
value-key="value"
icon="i-lucide-user-round"
:placeholder="t('All people')"
:search-input="false"
:aria-label="t('Person')"
color="neutral"
variant="outline"
class="w-full md:w-42"
:ui="{
base: 'h-11 rounded-none border border-accented bg-default ps-8 pe-8 text-sm ring-0 md:h-8',
leadingIcon: 'size-3.5 text-muted',
trailingIcon: 'size-3.5 text-muted',
placeholder: 'text-muted',
content: 'min-w-48',
}"
@update:open="(isOpen: boolean) => isOpen && emit('open')"
@update:model-value="onSelect($event as string | undefined)"
/>
</template>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts" generic="V extends string">
import { SEGMENT_GROUP, segmentClass } from '@/lib/segmentStyles'
defineProps<{
options: { value: V; label: string }[]
modelValue: V
label: string
block?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: V]
}>()
</script>
<template>
<div role="group" :aria-label="label" :class="[SEGMENT_GROUP, block ? 'w-full' : '']">
<UButton
v-for="option in options"
:key="option.value"
color="neutral"
:variant="option.value === modelValue ? 'soft' : 'outline'"
:aria-pressed="option.value === modelValue"
:class="segmentClass(option.value === modelValue, block)"
@click="emit('update:modelValue', option.value)"
>
{{ option.label }}
</UButton>
</div>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ActivityItem } from '@/lib/activity'
import { composeSentence } from '@/lib/activitySentence'
const props = defineProps<{
item: ActivityItem
}>()
const segments = computed(() => composeSentence(props.item))
</script>
<template>
<span data-testid="activity-sentence"
><template v-for="(segment, index) in segments" :key="index"
><span v-if="segment.strong" class="font-semibold text-highlighted">{{
segment.text
}}</span
><template v-else>{{ segment.text }}</template></template
></span
>
</template>
+141
View File
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ActivityFilters, ActivitySummary } from '@/lib/activity'
import {
barHeights,
STRIP_KEYS,
stripCards,
type StripCard,
type StripKey,
} from '@/lib/activitySummary'
const DESKTOP_BAR_HEIGHT = 20
const MOBILE_BAR_HEIGHT = 16
const props = defineProps<{
summary: ActivitySummary | null
range: ActivityFilters['range']
loading: boolean
isDesktop: boolean
outcome: ActivityFilters['outcome']
security: boolean
}>()
const emit = defineEmits<{
'apply-filter': [key: StripKey]
}>()
const cards = computed(() => (props.summary ? stripCards(props.summary, props.range) : []))
const barHeight = computed(() => (props.isDesktop ? DESKTOP_BAR_HEIGHT : MOBILE_BAR_HEIGHT))
const CARD_PADDING = 'px-3 py-2.5 md:px-3.5 md:py-3'
const BAR_TONES: Record<StripCard['tone'], string> = {
neutral: 'bg-(--ui-text-muted)',
error: 'bg-error',
warning: 'bg-warning',
calm: 'bg-(--ui-border-accented)',
}
const VALUE_TONES: Record<StripCard['tone'], string> = {
neutral: 'text-highlighted',
error: 'text-error',
warning: 'text-warning',
calm: 'text-highlighted',
}
function pressed(key: StripKey) {
if (key === 'failed') return props.outcome === 'failed'
if (key === 'security') return props.security
return false
}
function rootClass(card: StripCard) {
const fill =
card.tone === 'error'
? 'bg-(--rc-error-soft)'
: card.tone === 'warning'
? 'bg-(--rc-warning-soft)'
: 'bg-default'
const edge = pressed(card.key)
? 'border-primary ring-1 ring-primary ring-inset'
: 'border-default ring-0'
return [
'w-full cursor-pointer border text-left max-md:w-auto max-md:shrink-0 max-md:basis-39 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary',
fill,
edge,
]
}
</script>
<template>
<div
data-testid="activity-strip"
class="grid grid-cols-4 gap-3 max-md:-mx-4 max-md:flex max-md:gap-2 max-md:overflow-hidden max-md:px-4"
>
<template v-if="loading">
<UCard
v-for="key in STRIP_KEYS"
:key="key"
data-testid="activity-strip-skeleton"
:ui="{
root: 'border border-default bg-default ring-0 max-md:w-39 max-md:flex-none',
body: 'flex flex-col gap-2 p-3.5 sm:p-3.5 max-md:px-3 max-md:py-2.5 max-md:sm:px-3 max-md:sm:py-2.5',
}"
>
<USkeleton class="h-3 w-11/20 rounded-none bg-accented" />
<USkeleton class="h-5.5 w-2/5 rounded-none bg-accented" />
<USkeleton class="h-2.5 w-7/10 rounded-none bg-accented" />
</UCard>
</template>
<template v-else>
<UCard
v-for="card in cards"
:key="card.key"
as="button"
type="button"
data-testid="activity-strip-card"
:data-key="card.key"
:aria-pressed="
card.key === 'failed' || card.key === 'security' ? pressed(card.key) : undefined
"
:class="rootClass(card)"
:ui="{
root: 'overflow-visible rounded-none shadow-none md:min-w-0',
body: 'p-0 sm:p-0',
}"
@click="emit('apply-filter', card.key)"
>
<span class="flex flex-col gap-0.5" :class="CARD_PADDING">
<span class="flex items-center justify-between gap-2">
<span class="truncate font-medium text-toned">{{ card.label }}</span>
<span
aria-hidden="true"
data-testid="activity-bars"
class="flex shrink-0 items-end gap-0.5"
:style="{ height: `${barHeight}px` }"
>
<span
v-for="(height, index) in barHeights(card.series, barHeight)"
:key="index"
class="block w-1"
:class="BAR_TONES[card.tone]"
:style="{ height: `${height}px` }"
/>
</span>
</span>
<span
class="text-stat-sm font-semibold md:text-stat"
:class="VALUE_TONES[card.tone]"
>{{ card.value }}</span
>
<span
class="flex items-center gap-1 truncate text-xs/5"
:class="card.tone === 'calm' ? 'text-success' : 'text-muted'"
>
<UIcon v-if="card.icon" :name="card.icon" class="size-3 shrink-0" />
<span class="truncate">{{ card.subline }}</span>
</span>
</span>
</UCard>
</template>
</div>
</template>
+148
View File
@@ -0,0 +1,148 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import ActivityActor from '@/components/ActivityActor.vue'
import ActivityBadge from '@/components/ActivityBadge.vue'
import ActivitySentence from '@/components/ActivitySentence.vue'
import ActivityTargetCell from '@/components/ActivityTargetCell.vue'
import { ACTIVITY_SKELETON_ROWS } from '@/composables/useActivity'
import { categoryIcon, outcomeBadge, rowSurface, type ActivityItem } from '@/lib/activity'
import { fullLocalTime, groupByDay, whenLabel } from '@/lib/activityTime'
const FULL_GRID = 'grid-cols-[96px_210px_minmax(0,1fr)_230px_120px]'
const OWN_GRID = 'grid-cols-[96px_minmax(0,1fr)_230px_120px]'
const SKELETON_ACTION_WIDTHS = ['w-7/10', 'w-11/20', 'w-41/50']
const props = defineProps<{
items: ActivityItem[]
loading: boolean
selectedId: number | null
showActor: boolean
}>()
const emit = defineEmits<{
open: [item: ActivityItem, opener: HTMLElement]
}>()
const grid = computed(() => `grid ${props.showActor ? FULL_GRID : OWN_GRID}`)
const groups = computed(() => groupByDay(props.items))
function open(item: ActivityItem, event: Event) {
emit('open', item, event.currentTarget as HTMLElement)
}
function rowClass(item: ActivityItem) {
return [grid.value, ...rowSurface(props.selectedId === item.id, item.security)]
}
</script>
<template>
<div
role="table"
:aria-label="t('Activity')"
:aria-busy="loading ? 'true' : undefined"
class="flex flex-col overflow-hidden border border-default bg-default"
>
<div
role="row"
:class="grid"
class="h-9 shrink-0 items-center bg-elevated pr-4 font-medium text-muted"
>
<span role="columnheader" class="flex items-center gap-1 pl-4 text-highlighted">
{{ t('When') }}
<UIcon name="i-lucide-arrow-down" class="size-3 shrink-0" />
</span>
<span v-if="showActor" role="columnheader">{{ t('Actor') }}</span>
<span role="columnheader">{{ t('Action') }}</span>
<span role="columnheader">{{ t('Target') }}</span>
<span role="columnheader">{{ t('Outcome') }}</span>
</div>
<template v-if="loading">
<span role="status" class="sr-only">{{ t('Loading activity') }}</span>
<div
v-for="n in ACTIVITY_SKELETON_ROWS"
:key="n"
role="row"
:class="grid"
class="h-13 items-center border-t border-default pr-4"
data-testid="activity-skeleton-row"
>
<span class="pl-4"><USkeleton class="h-3 w-14 rounded-none bg-accented" /></span>
<span v-if="showActor" class="flex items-center gap-2">
<USkeleton class="size-6 shrink-0 rounded-none bg-accented" />
<USkeleton class="h-3 w-22.5 rounded-none bg-accented" />
</span>
<span>
<USkeleton
class="h-3 rounded-none bg-accented"
:class="SKELETON_ACTION_WIDTHS[(n - 1) % 3]"
/>
</span>
<span><USkeleton class="h-3 w-3/5 rounded-none bg-accented" /></span>
<span><USkeleton class="h-5 w-18 rounded-none bg-accented" /></span>
</div>
</template>
<template v-else>
<template v-for="group in groups" :key="group.key">
<div role="row" data-testid="activity-day">
<div
role="cell"
class="flex h-8 items-center border-t border-default bg-muted px-4 text-xs font-semibold text-highlighted"
>
{{ group.label }}
</div>
</div>
<div
v-for="item in group.items"
:key="item.id"
role="row"
tabindex="0"
data-testid="activity-row"
:data-activity-id="item.id"
:aria-selected="selectedId === item.id"
:class="rowClass(item)"
class="min-h-13 cursor-pointer items-center border-t border-default pr-4 hover:bg-elevated focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
@click="open(item, $event)"
@keydown.enter.self.prevent="open(item, $event)"
>
<span
role="cell"
data-testid="activity-when"
:title="fullLocalTime(item.created_at)"
class="pl-4 whitespace-nowrap text-toned"
>
{{ whenLabel(item.created_at) }}
</span>
<span v-if="showActor" role="cell" class="min-w-0 pr-3">
<ActivityActor :actor="item.actor" />
</span>
<span role="cell" class="flex min-w-0 items-center gap-2.5 pr-4">
<UIcon
:name="categoryIcon(item.category)"
class="size-4 shrink-0 text-muted"
/>
<span class="truncate text-default">
<ActivitySentence :item="item" />
</span>
<UIcon
v-if="item.security"
name="i-lucide-shield-alert"
role="img"
:aria-label="t('Security')"
class="size-3.5 shrink-0 text-warning"
/>
</span>
<span role="cell" class="min-w-0">
<ActivityTargetCell :target="item.target" />
</span>
<span role="cell" class="flex">
<ActivityBadge v-bind="outcomeBadge(item.outcome)" />
</span>
</div>
</template>
</template>
</div>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import ActivityBadge from '@/components/ActivityBadge.vue'
import { targetLink, targetTypeLabel, type ActivityTarget } from '@/lib/activity'
const props = defineProps<{
target: ActivityTarget | null
}>()
const link = computed(() => targetLink(props.target))
</script>
<template>
<span v-if="!target" class="text-muted">—</span>
<span v-else class="flex min-w-0 flex-col pr-3" data-testid="activity-target">
<span v-if="!target.exists" class="flex min-w-0 items-center gap-1.5">
<span class="min-w-0 flex-1 truncate text-muted line-through">{{ target.name }}</span>
<ActivityBadge tone="deleted" :label="t('Deleted')" />
</span>
<RouterLink
v-else-if="link"
:to="link"
class="flex min-w-0 items-center gap-1 text-highlighted underline underline-offset-3"
@click.stop
>
<span class="truncate">{{ target.name }}</span>
<UIcon name="i-lucide-arrow-up-right" class="size-3 shrink-0 text-muted" />
</RouterLink>
<span v-else class="truncate text-highlighted">{{ target.name }}</span>
<span class="text-xs/5 text-muted">{{ targetTypeLabel(target.type) }}</span>
</span>
</template>
+7
View File
@@ -92,6 +92,13 @@ const navItems = computed(() =>
to: '/users', to: '/users',
icon: 'i-lucide-users', icon: 'i-lucide-users',
}, },
{
get label() {
return t('Activity')
},
to: '/activity',
icon: 'i-lucide-history',
},
{ {
get label() { get label() {
return t('Integration') return t('Integration')
+62
View File
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import ActivityFeedCard from '@/components/ActivityFeedCard.vue'
import { useActivity } from '@/composables/useActivity'
import type { ActivityItem } from '@/lib/activity'
import { useSessionStore } from '@/stores/session'
const SKELETON_DELAY_MS = 150
const RECENT_SIZE = 5
const session = useSessionStore()
const { listRecentActivity } = useActivity()
const items = ref<ActivityItem[]>([])
const loading = ref(true)
const error = ref(false)
const showSkeleton = ref(false)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
const own = computed(() => session.session?.role !== 'owner' && session.session?.role !== 'admin')
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
skeletonTimer = null
}
}
async function load() {
loading.value = true
error.value = false
showSkeleton.value = false
clearSkeletonTimer()
skeletonTimer = setTimeout(() => {
showSkeleton.value = true
}, SKELETON_DELAY_MS)
try {
const page = await listRecentActivity(RECENT_SIZE)
items.value = page.items.slice(0, RECENT_SIZE)
} catch {
error.value = true
} finally {
clearSkeletonTimer()
showSkeleton.value = false
loading.value = false
}
}
onMounted(load)
onBeforeUnmount(clearSkeletonTimer)
</script>
<template>
<ActivityFeedCard
:items="items"
:loading="loading"
:show-skeleton="showSkeleton"
:error="error"
:own="own"
@retry="load"
/>
</template>
+337
View File
@@ -0,0 +1,337 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useOnline } from '@vueuse/core'
import { ApiError } from '@/composables/useApi'
import { useSettings, type WorkspaceSettings } from '@/composables/useSettings'
import {
SAVED_VISIBLE_MS,
helperCopy,
isShorter,
lastCleanupLine,
nextCleanupLine,
retentionOptions,
shorteningDescription,
} from '@/lib/activityRetention'
import { useSessionStore } from '@/stores/session'
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
const SKELETON_BAR = 'rounded-none bg-accented'
const OUTLINE_BUTTON =
'h-11 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7'
const HTTP_UNAUTHORIZED = 401
const HTTP_FORBIDDEN = 403
type SaveStatus = 'idle' | 'saving' | 'saved'
const props = defineProps<{
settings: WorkspaceSettings | null
loadState: 'loading' | 'ready' | 'error'
showSkeleton: boolean
}>()
const emit = defineEmits<{
saved: [settings: WorkspaceSettings]
reload: []
}>()
const session = useSessionStore()
const { updateRetention } = useSettings()
const online = useOnline()
const selected = ref(props.settings?.activity_retention_days ?? 0)
const shortening = ref<number | null>(null)
const failedValue = ref<number | null>(null)
const saveStatus = ref<SaveStatus>('idle')
let savedTimer: ReturnType<typeof setTimeout> | null = null
let pendingSaves = 0
const offline = computed(() => !online.value)
const skeletonVisible = computed(() => props.loadState === 'loading' && props.showSkeleton)
const savedDays = computed(() => props.settings?.activity_retention_days ?? 0)
const options = computed(() => retentionOptions())
watch(savedDays, (days) => {
if (shortening.value === null && pendingSaves === 0) {
selected.value = days
}
})
function clearSavedTimer() {
if (savedTimer !== null) {
clearTimeout(savedTimer)
savedTimer = null
}
}
function finishSaving(succeeded: boolean) {
if (!succeeded) {
saveStatus.value = 'idle'
return
}
saveStatus.value = 'saved'
savedTimer = setTimeout(() => {
saveStatus.value = 'idle'
}, SAVED_VISIBLE_MS)
}
async function save(days: number) {
pendingSaves += 1
clearSavedTimer()
failedValue.value = null
selected.value = days
saveStatus.value = 'saving'
let succeeded = false
try {
const updated = await updateRetention(days)
emit('saved', updated)
selected.value = updated.activity_retention_days
succeeded = true
} catch (error) {
selected.value = savedDays.value
const status = error instanceof ApiError ? error.status : 0
if (status !== HTTP_UNAUTHORIZED) {
failedValue.value = days
}
if (status === HTTP_FORBIDDEN) {
session.refresh()
}
} finally {
pendingSaves -= 1
if (pendingSaves === 0) {
finishSaving(succeeded)
}
}
}
function choose(value: number) {
if (offline.value) {
return
}
shortening.value = null
failedValue.value = null
if (value === savedDays.value) {
selected.value = value
return
}
if (isShorter(value, savedDays.value)) {
selected.value = value
shortening.value = value
return
}
save(value)
}
function apply() {
const days = shortening.value
shortening.value = null
if (days !== null) {
save(days)
}
}
function cancel() {
shortening.value = null
selected.value = savedDays.value
}
function retry() {
if (failedValue.value !== null) {
save(failedValue.value)
}
}
onBeforeUnmount(clearSavedTimer)
</script>
<template>
<UCard
as="section"
aria-labelledby="retention-h"
data-testid="retention-card"
:aria-busy="skeletonVisible ? 'true' : undefined"
:ui="{
root: 'rounded-none border border-default bg-default shadow-none ring-0 divide-y-0',
header: 'flex items-center gap-2 border-b border-default px-4 py-3 sm:px-4 sm:py-3',
body: 'flex flex-col gap-4 p-4 sm:p-4',
}"
>
<template #header>
<div class="flex min-w-0 flex-1 flex-col">
<h2 id="retention-h" class="text-base font-semibold text-highlighted">
{{ t('Retention') }}
</h2>
<p class="text-xs text-muted">{{ t('How long activity is kept.') }}</p>
</div>
<span
aria-live="polite"
data-testid="retention-save-status"
class="flex h-6 shrink-0 items-center"
>
<span
v-if="saveStatus === 'saving'"
class="inline-flex items-center gap-1.5 text-xs text-muted"
>
<UIcon name="i-lucide-loader-circle" class="size-3.5 animate-spin" />
{{ t('Saving…') }}
</span>
<span
v-else-if="saveStatus === 'saved'"
class="inline-flex items-center gap-1.5 text-xs text-success"
>
<UIcon name="i-lucide-check" class="size-3.5" />
{{ t('Saved') }}
</span>
</span>
</template>
<template v-if="skeletonVisible">
<USkeleton :class="[SKELETON_BAR, 'h-3.5 w-30']" />
<USkeleton :class="[SKELETON_BAR, 'h-8 w-full max-w-65']" />
<USkeleton :class="[SKELETON_BAR, 'h-3 w-3/5']" />
<USkeleton :class="[SKELETON_BAR, 'h-3.5 w-9/10']" />
<USkeleton :class="[SKELETON_BAR, 'h-3.5 w-7/10']" />
</template>
<UAlert
v-else-if="loadState === 'error'"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:description="t(UNREACHABLE_COPY)"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
:class="OUTLINE_BUTTON"
:ui="{ leadingIcon: 'size-3.5' }"
@click="emit('reload')"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<template v-else-if="loadState === 'ready' && settings">
<UAlert
v-if="failedValue !== null"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t save the retention period')"
:description="t('Your previous setting is still in place.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
:class="OUTLINE_BUTTON"
@click="retry"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<div class="flex max-w-65 flex-col gap-1.5">
<span id="retention-label" class="font-medium text-highlighted">
{{ t('Keep activity for') }}
</span>
<USelectMenu
:model-value="selected"
:items="options"
value-key="value"
:search-input="false"
:disabled="offline"
aria-labelledby="retention-label"
color="neutral"
variant="outline"
class="w-full"
:ui="{
base: 'h-11 rounded-none border border-accented bg-default px-2.5 text-sm ring-0 md:h-8',
trailingIcon: 'size-3.5 text-muted',
content: 'min-w-48',
}"
@update:model-value="choose($event as number)"
/>
<span class="text-xs text-muted">{{ helperCopy(selected) }}</span>
</div>
<UAlert
v-if="shortening !== null"
role="alert"
color="warning"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-triangle-alert"
:title="t('Events older than {0} days will be removed', [shortening])"
:description="shorteningDescription(settings)"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
:disabled="offline"
:class="OUTLINE_BUTTON"
@click="apply"
>
{{ t('Apply {0} days', [shortening]) }}
</UButton>
<UButton
color="neutral"
variant="outline"
:disabled="offline"
:class="OUTLINE_BUTTON"
@click="cancel"
>
{{ t('Cancel') }}
</UButton>
</template>
</UAlert>
<UAlert
role="status"
color="info"
variant="soft"
:close="false"
icon="i-lucide-lock"
:title="t('Activity logs are append-only')"
:description="
t(
'Events can\'t be edited or deleted from the app. Only the retention period removes them.',
)
"
/>
<USeparator />
<dl class="flex flex-col gap-1.5">
<div class="flex flex-col md:flex-row md:gap-3">
<dt class="shrink-0 text-muted md:w-30">{{ t('Last cleanup') }}</dt>
<dd data-testid="retention-last" class="text-highlighted">
{{ lastCleanupLine(settings) }}
</dd>
</div>
<div class="flex flex-col md:flex-row md:gap-3">
<dt class="shrink-0 text-muted md:w-30">{{ t('Next cleanup') }}</dt>
<dd data-testid="retention-next" class="text-highlighted">
{{ nextCleanupLine(settings) }}
</dd>
</div>
</dl>
</template>
</UCard>
</template>
@@ -58,8 +58,8 @@ afterEach(async () => {
document.body.innerHTML = '' document.body.innerHTML = ''
}) })
describe('DashboardShell renders the six navigation destinations', () => { describe('DashboardShell renders the seven navigation destinations', () => {
it('lists Home, Articles, Media, Users, Integration and Settings in order, with only Articles carrying aria-current', async () => { it('lists Home, Articles, Media, Users, Activity, Integration and Settings in order, with only Articles carrying aria-current', async () => {
const wrapper = await mountShell('/article') const wrapper = await mountShell('/article')
const hrefs = wrapper const hrefs = wrapper
@@ -70,6 +70,7 @@ describe('DashboardShell renders the six navigation destinations', () => {
'/article', '/article',
'/media', '/media',
'/users', '/users',
'/activity',
'/integration', '/integration',
'/settings', '/settings',
]) ])
@@ -85,7 +86,7 @@ describe('DashboardShell renders the six navigation destinations', () => {
expect(integration.attributes('aria-current')).toBeUndefined() expect(integration.attributes('aria-current')).toBeUndefined()
}) })
it('lists the same six destinations in the mobile drawer and Settings current', async () => { it('lists the same seven destinations in the mobile drawer and Settings current', async () => {
await mountShell('/settings', { width: MOBILE_WIDTH }) await mountShell('/settings', { width: MOBILE_WIDTH })
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
@@ -100,11 +101,12 @@ describe('DashboardShell renders the six navigation destinations', () => {
'/article', '/article',
'/media', '/media',
'/users', '/users',
'/activity',
'/integration', '/integration',
'/settings', '/settings',
]) ])
expect(links[5].querySelector('[class*="lucide"], svg')).not.toBeNull() expect(links[6].querySelector('[class*="lucide"], svg')).not.toBeNull()
expect(links[5].getAttribute('aria-current')).toBe('page') expect(links[6].getAttribute('aria-current')).toBe('page')
}) })
it('marks Media with aria-current on /media, in the sidebar and in the mobile drawer', async () => { it('marks Media with aria-current on /media, in the sidebar and in the mobile drawer', async () => {
@@ -263,11 +265,11 @@ describe('The account chip shows the display name and no item is current on /pro
expect(trigger?.getAttribute('aria-label')).toBe('Marta Alves — account menu') expect(trigger?.getAttribute('aria-label')).toBe('Marta Alves — account menu')
}) })
it('marks none of the six navigation items current on /profile', async () => { it('marks none of the seven navigation items current on /profile', async () => {
const wrapper = await mountShell('/profile') const wrapper = await mountShell('/profile')
const links = wrapper.findAll('nav[aria-label="Main"] a') const links = wrapper.findAll('nav[aria-label="Main"] a')
expect(links).toHaveLength(6) expect(links).toHaveLength(7)
links.forEach((link) => expect(link.attributes('aria-current')).toBeUndefined()) links.forEach((link) => expect(link.attributes('aria-current')).toBeUndefined())
}) })
}) })
+73
View File
@@ -0,0 +1,73 @@
import { useApi } from '@/composables/useApi'
import {
exportQuery,
listQuery,
summaryQuery,
type ActivityFilters,
type ActivityPage,
type ActivitySummary,
} from '@/lib/activity'
import type { UserListItem } from '@/composables/useUsers'
import type { ApiKey } from '@/composables/useApiKeys'
export interface PersonOptions {
users: { id: number; name: string }[]
keys: { id: number; name: string }[]
}
export const ACTIVITY_SKELETON_ROWS = 9
export const ACTIVITY_SKELETON_CARDS = 5
export const EXPORT_FILE_NAME = 'activity.csv'
export function useActivity() {
const { request, requestBlob } = useApi()
function listActivity(
filters: ActivityFilters,
page: number,
size: number,
withActor = true,
): Promise<ActivityPage> {
return request<ActivityPage>(`/v1/activity?${listQuery(filters, page, size, withActor)}`)
}
function listRecentActivity(size: number): Promise<ActivityPage> {
return request<ActivityPage>(`/v1/activity?size=${size}`)
}
function getSummary(filters: ActivityFilters): Promise<ActivitySummary> {
return request<ActivitySummary>(`/v1/activity/summary?${summaryQuery(filters)}`)
}
function exportActivity(filters: ActivityFilters): Promise<Blob> {
return requestBlob(`/v1/activity/export?${exportQuery(filters)}`)
}
async function listPeople(): Promise<PersonOptions> {
const [users, keys] = await Promise.all([
request<UserListItem[]>('/v1/user/'),
request<ApiKey[]>('/v1/api-key/'),
])
return {
users: users.flatMap((item) =>
item.kind === 'user'
? [{ id: item.id, name: item.display_name || item.username }]
: [],
),
keys: keys.map((key) => ({ id: key.id, name: key.name })),
}
}
return { listActivity, listRecentActivity, getSummary, exportActivity, listPeople }
}
export function downloadBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(url)
}
+316
View File
@@ -0,0 +1,316 @@
import { computed, onBeforeUnmount, ref } from 'vue'
import { t } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation'
import { ApiError } from '@/composables/useApi'
import {
downloadBlob,
EXPORT_FILE_NAME,
useActivity,
type PersonOptions,
} from '@/composables/useActivity'
import { useSessionStore } from '@/stores/session'
import {
activeChips,
clearChip,
defaultFilters,
DEFAULT_PAGE_SIZE,
summaryRangeChanged,
type ActivityFilters,
type ActivityItem,
type ActivitySummary,
type ChipKey,
} from '@/lib/activity'
export type ListState = 'idle' | 'loading' | 'ready' | 'error' | 'forbidden'
export type SummaryState = 'idle' | 'loading' | 'ready' | 'failed'
const SKELETON_DELAY_MS = 150
const HANDLED_STATUSES = [401]
function delayedFlag() {
const flag = ref(false)
let timer: ReturnType<typeof setTimeout> | null = null
function stop() {
if (timer !== null) {
clearTimeout(timer)
timer = null
}
flag.value = false
}
function arm() {
stop()
timer = setTimeout(() => {
timer = null
flag.value = true
}, SKELETON_DELAY_MS)
}
return { flag, arm, stop }
}
export function useActivityPage() {
const session = useSessionStore()
const toast = useLocalizedToast()
const { listActivity, getSummary, exportActivity, listPeople } = useActivity()
const filters = ref<ActivityFilters>(defaultFilters())
const page = ref(1)
const size = ref(DEFAULT_PAGE_SIZE)
const items = ref<ActivityItem[]>([])
const total = ref(0)
const listState = ref<ListState>('idle')
const summary = ref<ActivitySummary | null>(null)
const summaryState = ref<SummaryState>('idle')
const people = ref<PersonOptions | null>(null)
const exporting = ref(false)
const selectedId = ref<number | null>(null)
const navigating = ref(false)
const listSkeleton = delayedFlag()
const summarySkeleton = delayedFlag()
let listSequence = 0
let summarySequence = 0
let peopleRequested = false
let requestedPage = 1
const role = computed(() => session.session?.role as string | undefined)
const isFullView = computed(() => role.value === 'owner' || role.value === 'admin')
const chips = computed(() => activeChips(filters.value))
const showListSkeleton = computed(
() => listState.value === 'loading' && listSkeleton.flag.value,
)
const showSummarySkeleton = computed(
() => summaryState.value === 'loading' && summarySkeleton.flag.value,
)
const isEmpty = computed(() => listState.value === 'ready' && total.value === 0)
const exportDisabled = computed(
() =>
exporting.value ||
listState.value === 'error' ||
(isEmpty.value && chips.value.length === 0),
)
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / size.value)))
const rangeStart = computed(() => (total.value === 0 ? 0 : (page.value - 1) * size.value + 1))
const rangeEnd = computed(() => Math.min(total.value, page.value * size.value))
const selectedIndex = computed(() =>
items.value.findIndex((item) => item.id === selectedId.value),
)
const selectedItem = computed(() => items.value[selectedIndex.value] ?? null)
const position = computed(() =>
selectedIndex.value < 0 ? 0 : (page.value - 1) * size.value + selectedIndex.value + 1,
)
const canPrevious = computed(() => position.value > 1 && !navigating.value)
const canNext = computed(
() => position.value > 0 && position.value < total.value && !navigating.value,
)
function failureState(error: unknown): ListState {
if (error instanceof ApiError && HANDLED_STATUSES.includes(error.status)) return 'idle'
if (error instanceof ApiError && error.status === 403) {
return session.session?.verified === false ? 'idle' : 'forbidden'
}
return 'error'
}
async function fetchPage(
nextPage: number,
options: { silent?: boolean; select?: 'first' | 'last' } = {},
) {
const sequence = ++listSequence
if (!options.silent) {
requestedPage = nextPage
listState.value = 'loading'
listSkeleton.arm()
}
try {
const result = await listActivity(filters.value, nextPage, size.value, isFullView.value)
if (sequence !== listSequence) return false
items.value = result.items
total.value = result.total
page.value = nextPage
listState.value = 'ready'
if (options.select && result.items.length > 0) {
const target = options.select === 'first' ? 0 : result.items.length - 1
selectedId.value = result.items[target]!.id
}
return true
} catch (error) {
if (sequence !== listSequence) return false
if (!options.silent) listState.value = failureState(error)
return false
} finally {
if (sequence === listSequence) listSkeleton.stop()
}
}
function loadList() {
return fetchPage(1)
}
async function loadSummary() {
if (!isFullView.value) return
const sequence = ++summarySequence
summaryState.value = 'loading'
summarySkeleton.arm()
try {
const result = await getSummary(filters.value)
if (sequence !== summarySequence) return
summary.value = result
summaryState.value = 'ready'
} catch {
if (sequence !== summarySequence) return
summary.value = null
summaryState.value = 'failed'
} finally {
if (sequence === summarySequence) summarySkeleton.stop()
}
}
function start() {
loadList()
loadSummary()
}
function applyFilters(next: ActivityFilters, force = false) {
const current = filters.value
if (!force && JSON.stringify(next) === JSON.stringify(current)) return
const rangeChanged = summaryRangeChanged(current, next)
filters.value = next
loadList()
if (rangeChanged) loadSummary()
}
function removeChip(key: ChipKey) {
applyFilters(clearChip(filters.value, key))
}
function clearAll() {
applyFilters(defaultFilters(), true)
}
function goToPage(next: number) {
if (next === page.value || next < 1 || next > pageCount.value) return
fetchPage(next)
}
function setSize(next: number) {
if (next === size.value) return
size.value = next
fetchPage(1)
}
function retry() {
fetchPage(requestedPage)
}
async function ensurePeople() {
if (peopleRequested) return
peopleRequested = true
try {
people.value = await listPeople()
} catch {
peopleRequested = false
}
}
async function runExport() {
if (exportDisabled.value) return
exporting.value = true
try {
const blob = await exportActivity(filters.value)
downloadBlob(blob, EXPORT_FILE_NAME)
toast.add(() => ({
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Export started. Your file will download shortly.')
},
}))
} catch (error) {
if (!(error instanceof ApiError && error.status === 401)) {
toast.add(() => ({
color: 'error',
icon: 'i-lucide-circle-x',
get title() {
return t("Couldn't export activity. Try again.")
},
}))
}
} finally {
exporting.value = false
}
}
function openDetails(id: number) {
selectedId.value = id
}
function closeDetails() {
selectedId.value = null
}
async function step(direction: -1 | 1) {
const index = selectedIndex.value
if (index < 0 || navigating.value || listState.value === 'loading') return
const target = index + direction
const neighbour = items.value[target]
if (neighbour) {
selectedId.value = neighbour.id
return
}
if (direction === 1 ? position.value >= total.value : page.value <= 1) return
navigating.value = true
await fetchPage(page.value + direction, {
silent: true,
select: direction === 1 ? 'first' : 'last',
})
navigating.value = false
}
onBeforeUnmount(() => {
listSkeleton.stop()
summarySkeleton.stop()
})
return {
filters,
page,
size,
items,
total,
listState,
summary,
summaryState,
people,
exporting,
chips,
isFullView,
isEmpty,
exportDisabled,
showListSkeleton,
showSummarySkeleton,
pageCount,
rangeStart,
rangeEnd,
selectedId,
selectedItem,
position,
canPrevious,
canNext,
start,
applyFilters,
removeChip,
clearAll,
goToPage,
setSize,
retry,
ensurePeople,
runExport,
openDetails,
closeDetails,
previous: () => step(-1),
next: () => step(1),
}
}
+13 -2
View File
@@ -101,7 +101,7 @@ export function redirectUnverified(onForbidden: OnForbidden = 'redirect') {
} }
export function useApi() { export function useApi() {
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> { async function send(path: string, options: RequestOptions = {}): Promise<Response> {
const { onUnauthorized = 'redirect', onForbidden = 'redirect', ...init } = options const { onUnauthorized = 'redirect', onForbidden = 'redirect', ...init } = options
let response: Response let response: Response
@@ -132,6 +132,12 @@ export function useApi() {
throw new ApiError(response.status, undefined, await readErrorBody(response)) throw new ApiError(response.status, undefined, await readErrorBody(response))
} }
return response
}
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const response = await send(path, options)
if (response.status === 204) { if (response.status === 204) {
return undefined as T return undefined as T
} }
@@ -139,6 +145,11 @@ export function useApi() {
return response.json() return response.json()
} }
async function requestBlob(path: string, options: RequestOptions = {}): Promise<Blob> {
const response = await send(path, options)
return response.blob()
}
async function getMe(): Promise<MeResult> { async function getMe(): Promise<MeResult> {
const body = await request<{ const body = await request<{
id?: number id?: number
@@ -158,5 +169,5 @@ export function useApi() {
} }
} }
return { getMe, request } return { getMe, request, requestBlob }
} }
+17 -2
View File
@@ -2,14 +2,29 @@ import { useApi } from '@/composables/useApi'
export interface WorkspaceSettings { export interface WorkspaceSettings {
version: string version: string
activity_retention_days: number
activity_last_cleanup_at: string | null
activity_last_cleanup_removed: number
activity_next_cleanup_at: string | null
} }
const SETTINGS_PATH = '/v1/settings/'
const JSON_HEADERS = { 'Content-Type': 'application/json' }
export function useSettings() { export function useSettings() {
const { request } = useApi() const { request } = useApi()
function getSettings(): Promise<WorkspaceSettings> { function getSettings(): Promise<WorkspaceSettings> {
return request<WorkspaceSettings>('/v1/settings/') return request<WorkspaceSettings>(SETTINGS_PATH)
} }
return { getSettings } function updateRetention(days: number): Promise<WorkspaceSettings> {
return request<WorkspaceSettings>(SETTINGS_PATH, {
method: 'PATCH',
headers: JSON_HEADERS,
body: JSON.stringify({ activity_retention_days: days }),
})
}
return { getSettings, updateRetention }
} }
+211
View File
@@ -1004,6 +1004,217 @@ export const messages = {
'La médiathèque a changé, rien n’a donc été purgé. Vérifiez le nouveau récapitulatif et réessayez.', 'La médiathèque a changé, rien n’a donc été purgé. Vérifiez le nouveau récapitulatif et réessayez.',
'A purge is already running.': 'Une purge est déjà en cours.', 'A purge is already running.': 'Une purge est déjà en cours.',
'Duplicates purged': 'Doublons purgés', 'Duplicates purged': 'Doublons purgés',
Activity: 'Activité',
'My activity': 'Mon activité',
'Who did what, and when, across your site.': 'Qui a fait quoi, et quand, sur votre site.',
"Everything you've done on this site.": 'Tout ce que vous avez fait sur ce site.',
'Export CSV': 'Exporter en CSV',
'Preparing file…': 'Préparation du fichier…',
'Export started. Your file will download shortly.':
'Export lancé. Votre fichier va se télécharger sous peu.',
"Couldn't export activity. Try again.": "Impossible d'exporter l'activité. Réessayez.",
'Total actions': 'Actions au total',
'Active people': 'Personnes actives',
'Failed actions': 'Actions en échec',
'Security events': 'Événements de sécurité',
'{0}% vs previous 7 days': '{0} % par rapport aux 7 jours précédents',
'{0}% vs previous day': '{0} % par rapport au jour précédent',
'{0}% vs previous 30 days': '{0} % par rapport aux 30 jours précédents',
'{0}% vs previous period': '{0} % par rapport à la période précédente',
'No earlier data': 'Aucune donnée antérieure',
'No change': 'Aucun changement',
'No data yet': 'Aucune donnée pour le moment',
'{0} people': '{0} personnes',
'1 person': '1 personne',
'{0} API keys': "{0} clés d'API",
'1 API key': "1 clé d'API",
'{0}% of all actions': '{0} % de toutes les actions',
'{0} flagged': '{0} signalés',
'1 flagged': '1 signalé',
'Nothing failed': 'Aucun échec',
'Nothing to flag': 'Rien à signaler',
'Search activity': "Rechercher dans l'activité",
'All people': 'Toutes les personnes',
'All categories': 'Toutes les catégories',
Succeeded: 'Réussie',
Failed: 'Échec',
'7 days': '7 jours',
'30 days': '30 jours',
Custom: 'Personnalisée',
Person: 'Personne',
Category: 'Catégorie',
Outcome: 'Résultat',
Dates: 'Dates',
Filters: 'Filtres',
'Clear all': 'Tout effacer',
'Search: {0}': 'Recherche : {0}',
'Person: {0}': 'Personne : {0}',
'Category: {0}': 'Catégorie : {0}',
'Outcome: {0}': 'Résultat : {0}',
'Dates: {0}': 'Dates : {0}',
'Remove filter {0}': 'Retirer le filtre {0}',
'Show {0} results': 'Afficher {0} résultats',
'Show 1 result': 'Afficher 1 résultat',
'Showing {0}–{1} of {2}': 'Affichage de {0} à {1} sur {2}',
'Page {0} of {1}': 'Page {0} sur {1}',
'{0}–{1} of {2}': '{0} à {1} sur {2}',
'Rows per page': 'Lignes par page',
'Previous page': 'Page précédente',
'Next page': 'Page suivante',
'Start date': 'Date de début',
'End date': 'Date de fin',
When: 'Quand',
Actor: 'Acteur',
Action: 'Action',
Target: 'Cible',
'Not signed in': 'Non connecté',
Deleted: 'Supprimé',
'Target deleted': 'Cible supprimée',
Security: 'Sécurité',
Article: 'Article',
User: 'Utilisateur',
Invite: 'Invitation',
Release: 'Version',
'{0} min ago': 'Il y a {0} min',
'{0} h ago': 'Il y a {0} h',
'No activity yet': 'Aucune activité pour le moment',
'Actions on articles, media, users and API keys will appear here.':
"Les actions sur les articles, les médias, les utilisateurs et les clés d'API apparaîtront ici.",
'Your actions will appear here once you start working on the site.':
'Vos actions apparaîtront ici dès que vous commencerez à travailler sur le site.',
'No activity matches these filters': 'Aucune activité ne correspond à ces filtres',
'Try a different search or a wider date range.':
'Essayez une autre recherche ou une période plus large.',
"Couldn't load activity": "Impossible de charger l'activité",
"You can't view this activity": 'Vous ne pouvez pas consulter cette activité',
"Your role doesn't include this page. Ask an owner or admin if you need access.":
"Votre rôle n'inclut pas cette page. Demandez à un propriétaire ou à un administrateur si vous avez besoin d'y accéder.",
'Back to home': "Retour à l'accueil",
'Loading activity': "Chargement de l'activité",
'Activity details': "Détails de l'activité",
'Why it failed': "Raison de l'échec",
Source: 'Source',
'IP address': 'Adresse IP',
Browser: 'Navigateur',
'Web app': 'Application web',
'System task': 'Tâche système',
'Not applicable': 'Sans objet',
Changes: 'Modifications',
Before: 'Avant',
After: 'Après',
'Show more': 'Voir plus',
'Show less': 'Voir moins',
'Previous event': 'Événement précédent',
'Next event': 'Événement suivant',
'Event {0} of {1}': 'Événement {0} sur {1}',
'{0} of {1}': '{0} sur {1}',
'{0} (your time)': '{0} (votre heure)',
'{0} UTC': '{0} UTC',
'This item was deleted. Its name is kept for reference.':
'Cet élément a été supprimé. Son nom est conservé pour référence.',
'Body edited ({0} characters)': 'Corps modifié ({0} caractères)',
'Body edited ({0} character)': 'Corps modifié ({0} caractère)',
'Publish state': 'État de publication',
Body: 'Corps',
'File name': 'Nom du fichier',
Published: 'Publié',
'Created the article {0}': 'Article créé : {0}',
'Edited the article {0}': 'Article modifié : {0}',
'Published the article {0}': 'Article publié : {0}',
'Unpublished the article {0}': 'Article dépublié : {0}',
'Deleted the article {0}': 'Article supprimé : {0}',
'Uploaded the image {0}': 'Image téléversée : {0}',
'Renamed the image {0}': 'Image renommée : {0}',
'Deleted the image {0}': 'Image supprimée : {0}',
'Merged duplicates into the image {0}': "Doublons fusionnés dans l'image {0}",
'Purged duplicate images: {0}': 'Doublons purgés : {0}',
'Ran the scheduled duplicate purge: {0}': 'Purge automatique des doublons : {0}',
'{0} articles updated': '{0} articles mis à jour',
'1 article updated': '1 article mis à jour',
'{0} groups failed': '{0} groupes en échec',
'1 group failed': '1 groupe en échec',
'Registered the account {0}': 'Compte créé : {0}',
'Verified the account {0}': 'Compte vérifié : {0}',
'Updated the profile of {0}': 'Profil mis à jour : {0}',
'Invited {0}': 'Invitation envoyée à {0}',
'Cancelled the invite for {0}': 'Invitation annulée : {0}',
'Renewed the invite for {0}': 'Invitation renouvelée : {0}',
'Accepted the invite for {0}': 'Invitation acceptée : {0}',
'Created API key {0}': "Clé d'API créée : {0}",
'Revoked API key {0}': "Clé d'API révoquée : {0}",
'Logged in': 'Connexion',
'Logged out': 'Déconnexion',
'Requested a password reset': 'Réinitialisation du mot de passe demandée',
'Reset the password': 'Mot de passe réinitialisé',
'Checked for updates': 'Recherche de mises à jour',
'Downloaded the update {0}': 'Mise à jour téléchargée : {0}',
'Requested a restart': 'Redémarrage demandé',
'Restarted to apply the update {0}': 'Redémarrage pour appliquer la mise à jour {0}',
'Changed the activity retention from {0} to {1}':
"Durée de conservation de l'activité modifiée : {0} → {1}",
'Removed {0} old events from the activity log':
"{0} anciens événements supprimés du journal d'activité",
'Removed 1 old event from the activity log':
"1 ancien événement supprimé du journal d'activité",
'{0} days': '{0} jours',
Forever: 'Toujours',
'Failed to create the article {0}': "Échec de la création de l'article {0}",
'Failed to edit the article {0}': "Échec de la modification de l'article {0}",
'Failed to publish the article {0}': "Échec de la publication de l'article {0}",
'Failed to unpublish the article {0}': "Échec de la dépublication de l'article {0}",
'Failed to delete the article {0}': "Échec de la suppression de l'article {0}",
'Failed to upload the image {0}': "Échec du téléversement de l'image {0}",
'Failed to rename the image {0}': "Échec du renommage de l'image {0}",
'Failed to delete the image {0}': "Échec de la suppression de l'image {0}",
'Failed to merge duplicates into the image {0}':
"Échec de la fusion des doublons dans l'image {0}",
'Failed to purge duplicate images': 'Échec de la purge des doublons',
'Failed to run the scheduled duplicate purge': 'Échec de la purge automatique des doublons',
'Failed to register the account {0}': 'Échec de la création du compte {0}',
'Failed to verify the account {0}': 'Échec de la vérification du compte {0}',
'Failed to update the profile of {0}': 'Échec de la mise à jour du profil de {0}',
'Failed to invite {0}': "Échec de l'invitation de {0}",
'Failed to cancel the invite for {0}': "Échec de l'annulation de l'invitation de {0}",
'Failed to renew the invite for {0}': "Échec du renouvellement de l'invitation de {0}",
'Failed to accept the invite for {0}': "Échec de l'acceptation de l'invitation de {0}",
'Failed to create API key {0}': "Échec de la création de la clé d'API {0}",
'Failed to revoke API key {0}': "Échec de la révocation de la clé d'API {0}",
'Failed login attempt for {0}': 'Échec de connexion pour {0}',
'Failed to check for updates': 'Échec de la recherche de mises à jour',
'Failed to download the update {0}': 'Échec du téléchargement de la mise à jour {0}',
'Failed to restart': 'Échec du redémarrage',
'Recent activity': 'Activité récente',
'Your recent activity': 'Votre activité récente',
'View all': 'Tout voir',
"Couldn't load recent activity": 'Impossible de charger l’activité récente',
'Actions on your site will appear here.': 'Les actions sur votre site apparaîtront ici.',
'Your actions will appear here.': 'Vos actions apparaîtront ici.',
Retention: 'Conservation',
'How long activity is kept.': 'Durée de conservation de l’activité.',
'Keep activity for': 'Conserver l’activité pendant',
'90 days': '90 jours',
'365 days': '365 jours',
'Older events are removed automatically.':
'Les anciens événements sont supprimés automatiquement.',
'Nothing is removed automatically. The log grows over time.':
'Rien n’est supprimé automatiquement. Le journal grossit avec le temps.',
'Activity logs are append-only': 'Le journal d’activité est en ajout seul',
"Events can't be edited or deleted from the app. Only the retention period removes them.":
'Les événements ne peuvent pas être modifiés ni supprimés depuis l’application. Seule la durée de conservation les supprime.',
'Last cleanup': 'Dernier nettoyage',
'Next cleanup': 'Prochain nettoyage',
'Not scheduled': 'Non planifié',
'{0} event removed': '{0} événement supprimé',
'{0} events removed': '{0} événements supprimés',
'Events older than {0} days will be removed':
'Les événements de plus de {0} jours seront supprimés',
"This happens at the next cleanup on {0}. It can't be undone.":
'Cela se produira au prochain nettoyage, le {0}. Cette action est irréversible.',
"Older events will be removed at the next cleanup. It can't be undone.":
'Les anciens événements seront supprimés au prochain nettoyage. Cette action est irréversible.',
'Apply {0} days': 'Appliquer {0} jours',
"Couldn't save the retention period": 'Impossible d’enregistrer la durée de conservation',
'Your previous setting is still in place.': 'Votre réglage précédent est conservé.',
} as const } as const
export type MessageKey = keyof typeof messages export type MessageKey = keyof typeof messages
+305
View File
@@ -0,0 +1,305 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { initializeLocale, setLocale } from '@/i18n'
import {
activeChips,
bodyDeltaLabel,
changeValue,
clearChip,
defaultFilters,
exportQuery,
fieldLabel,
initials,
listQuery,
summaryQuery,
targetLink,
type ActivityFilters,
} from '../activity'
import { dayLabel, formatDateRange, groupByDay, whenLabel } from '../activityTime'
import { barHeights, stripCards } from '../activitySummary'
import { summaryDefault, summaryNone, summaryZero } from '@/views/__tests__/activityFixtures'
beforeEach(() => {
localStorage.clear()
initializeLocale()
})
afterEach(() => {
localStorage.clear()
initializeLocale()
})
function filters(overrides: Partial<ActivityFilters> = {}): ActivityFilters {
return { ...defaultFilters(), ...overrides }
}
function entries(query: string) {
return Object.fromEntries(new URLSearchParams(query))
}
describe('Queries', () => {
it('always sends range, page and size and nothing else by default', () => {
expect(entries(listQuery(filters(), 1, 25))).toEqual({ range: '7d', page: '1', size: '25' })
})
it('adds q, actor, category, outcome and security only when set', () => {
const query = listQuery(
filters({
q: 'spring',
actor: 'user:3',
category: 'auth',
outcome: 'failed',
security: true,
}),
2,
50,
)
expect(entries(query)).toEqual({
q: 'spring',
actor: 'user:3',
category: 'auth',
outcome: 'failed',
security: 'true',
range: '7d',
page: '2',
size: '50',
})
})
it('sends from and to only with the custom range', () => {
expect(
entries(
listQuery(filters({ range: '30d', from: '2026-09-01', to: '2026-09-30' }), 1, 25),
),
).toEqual({ range: '30d', page: '1', size: '25' })
expect(
entries(
listQuery(
filters({ range: 'custom', from: '2026-09-01', to: '2026-09-30' }),
1,
25,
),
),
).toEqual({ range: 'custom', from: '2026-09-01', to: '2026-09-30', page: '1', size: '25' })
})
it('never sends an actor for a regular user', () => {
expect(entries(listQuery(filters({ actor: 'system' }), 1, 25, false)).actor).toBeUndefined()
})
it('keeps the summary to the date range and the export to the list filters', () => {
const all = filters({
q: 'x',
category: 'media',
range: 'custom',
from: '2026-09-01',
to: '2026-09-30',
})
expect(entries(summaryQuery(all))).toEqual({
range: 'custom',
from: '2026-09-01',
to: '2026-09-30',
})
expect(entries(exportQuery(all))).toEqual({
q: 'x',
category: 'media',
range: 'custom',
from: '2026-09-01',
to: '2026-09-30',
})
})
})
describe('Chips', () => {
it('lists the active filters in order and never the default range', () => {
expect(activeChips(filters())).toEqual([])
expect(
activeChips(
filters({
q: 'spring',
actor: 'user:3',
actorLabel: 'Marta Alves',
category: 'auth',
outcome: 'failed',
security: true,
range: '30d',
}),
).map((chip) => chip.label),
).toEqual([
'Search: spring',
'Person: Marta Alves',
'Category: Authentication',
'Outcome: Failed',
'Security events',
'Dates: 30 days',
])
})
it('words custom ranges', () => {
const range = (from: string, to: string) =>
activeChips(filters({ range: 'custom', from, to }))[0]!.label
expect(range('2026-09-01', '2026-09-30')).toBe('Dates: 1 – 30 Sep 2026')
expect(range('2026-09-28', '2026-10-03')).toBe('Dates: 28 Sep – 3 Oct 2026')
expect(range('2025-12-28', '2026-01-03')).toBe('Dates: 28 Dec 2025 – 3 Jan 2026')
expect(range('2026-09-05', '2026-09-05')).toBe('Dates: 5 Sep 2026')
})
it('words custom ranges in French', () => {
setLocale('fr')
expect(formatDateRange('2026-10-01', '2026-10-30')).toBe('1 – 30 oct. 2026')
})
it('resets one filter at a time', () => {
const all = filters({ q: 'a', actor: 'system', actorLabel: 'System', range: '30d' })
expect(clearChip(all, 'q').q).toBe('')
expect(clearChip(all, 'actor')).toMatchObject({ actor: '', actorLabel: '' })
expect(clearChip(all, 'range').range).toBe('7d')
})
})
describe('Times', () => {
const now = new Date(2026, 9, 6, 15, 0, 0)
it('labels days as Today, Yesterday or the date', () => {
expect(dayLabel(new Date(2026, 9, 6, 9, 0).toISOString(), now)).toBe('Today')
expect(dayLabel(new Date(2026, 9, 5, 23, 59).toISOString(), now)).toBe('Yesterday')
expect(dayLabel(new Date(2026, 9, 3, 14, 20).toISOString(), now)).toBe('3 Oct 2026')
setLocale('fr')
expect(dayLabel(new Date(2026, 9, 6, 9, 0).toISOString(), now)).toBe('Aujourd’hui')
expect(dayLabel(new Date(2026, 9, 3, 14, 20).toISOString(), now)).toBe('3 oct. 2026')
})
it('reads relative within today and as a clock time before', () => {
expect(whenLabel(new Date(2026, 9, 6, 14, 58).toISOString(), now)).toBe('2 min ago')
expect(whenLabel(new Date(2026, 9, 6, 14, 31).toISOString(), now)).toBe('29 min ago')
expect(whenLabel(new Date(2026, 9, 6, 14, 0).toISOString(), now)).toBe('1 h ago')
expect(whenLabel(new Date(2026, 9, 5, 18, 20).toISOString(), now)).toBe('18:20')
expect(whenLabel(new Date(2026, 9, 6, 14, 59, 50).toISOString(), now)).toBe('1 min ago')
})
it('groups consecutive items of the same local day in API order', () => {
const groups = groupByDay(
[
{ created_at: new Date(2026, 9, 6, 12).toISOString(), id: 1 },
{ created_at: new Date(2026, 9, 6, 9).toISOString(), id: 2 },
{ created_at: new Date(2026, 9, 5, 20).toISOString(), id: 3 },
],
now,
)
expect(groups.map((group) => [group.label, group.items.map((entry) => entry.id)])).toEqual([
['Today', [1, 2]],
['Yesterday', [3]],
])
})
})
describe('Labels', () => {
it('takes the first letters of the first two words', () => {
expect(initials('Marta Alves')).toBe('MA')
expect(initials('inès duarte jr')).toBe('ID')
expect(initials('Madonna')).toBe('M')
})
it('links each target type, and nothing for a deleted or unknown one', () => {
const link = (type: string, exists = true) =>
targetLink({ type, id: '8', name: 'x', exists })
expect(link('article')).toBe('/article/8/edit')
expect(link('image')).toBe('/media')
expect(link('user')).toBe('/users')
expect(link('invite')).toBe('/users')
expect(link('api_key')).toBe('/integration/api-keys')
expect(link('release')).toBeNull()
expect(link('article', false)).toBeNull()
expect(targetLink(null)).toBeNull()
})
it('labels fields and values, keeping unknown codes', () => {
expect(fieldLabel('title')).toBe('Title')
expect(fieldLabel('online')).toBe('Publish state')
expect(fieldLabel('mystery')).toBe('mystery')
expect(changeValue('online', 'false')).toBe('Draft')
expect(changeValue('online', 'true')).toBe('Published')
expect(changeValue('title', 'true')).toBe('true')
setLocale('fr')
expect(fieldLabel('filename')).toBe('Nom du fichier')
expect(changeValue('online', 'true')).toBe('Publié')
})
it('words body deltas with sign and singular', () => {
expect(bodyDeltaLabel(312)).toBe('Body edited (+312 characters)')
expect(bodyDeltaLabel(-48)).toBe('Body edited (-48 characters)')
expect(bodyDeltaLabel(1)).toBe('Body edited (+1 character)')
})
})
describe('Summary strip', () => {
it('derives the four cards from the summary', () => {
const cards = stripCards(summaryDefault, '7d')
expect(cards.map((card) => [card.label, card.value, card.subline])).toEqual([
['Total actions', '1,284', '12% vs previous 7 days'],
['Active people', '9', '7 people, 2 API keys'],
['Failed actions', '6', '0.5% of all actions'],
['Security events', '11', '11 flagged'],
])
expect(cards.map((card) => card.tone)).toEqual(['neutral', 'neutral', 'error', 'warning'])
expect(cards[0]!.icon).toBe('i-lucide-trending-up')
})
it('words the comparison for each range', () => {
const subline = (range: 'today' | '7d' | '30d' | 'custom') =>
stripCards(summaryDefault, range)[0]!.subline
expect(subline('today')).toBe('12% vs previous day')
expect(subline('30d')).toBe('12% vs previous 30 days')
expect(subline('custom')).toBe('12% vs previous period')
})
it('goes calm on zero and reads No data yet on an empty site', () => {
const quiet = stripCards(summaryZero, '7d')
expect(quiet[0]!.icon).toBe('i-lucide-trending-down')
expect(quiet.slice(2).map((card) => [card.tone, card.subline, card.icon])).toEqual([
['calm', 'Nothing failed', 'i-lucide-circle-check'],
['calm', 'Nothing to flag', 'i-lucide-circle-check'],
])
const none = stripCards(summaryNone, '7d')
expect(none.map((card) => card.subline)).toEqual([
'No data yet',
'No data yet',
'Nothing failed',
'Nothing to flag',
])
})
it('words singulars, the French forms and the flag count', () => {
const single = stripCards(
{ ...summaryDefault, active_people: { users: 1, api_keys: 1 }, security: 1 },
'7d',
)
expect(single[1]!.subline).toBe('1 person, 1 API key')
expect(single[3]!.subline).toBe('1 flagged')
setLocale('fr')
expect(stripCards(summaryDefault, '7d')[3]!.subline).toBe('11 signalés')
expect(stripCards({ ...summaryDefault, security: 1 }, '7d')[3]!.subline).toBe('1 signalé')
expect(
stripCards({ ...summaryDefault, total: 40, previous_total: 0 }, '7d')[0]!.subline,
).toBe('Aucune donnée antérieure')
expect(
stripCards({ ...summaryDefault, total: 40, previous_total: 40 }, '7d')[0]!.subline,
).toBe('Aucun changement')
})
it('sizes bars from the peak with a 2 px minimum', () => {
expect(barHeights([4, 6, 5, 8, 7, 9, 6], 20)).toEqual([9, 13, 11, 18, 16, 20, 13])
expect(barHeights([0, 0, 0, 0, 0, 0, 0], 20)).toEqual([2, 2, 2, 2, 2, 2, 2])
expect(barHeights([0, 1, 0, 0, 0, 0, 100], 16)).toEqual([2, 2, 2, 2, 2, 2, 16])
})
})
@@ -0,0 +1,88 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { initializeLocale, setLocale } from '@/i18n'
import type { WorkspaceSettings } from '@/composables/useSettings'
import { recentWhenLabel } from '../activityTime'
import {
helperCopy,
isShorter,
lastCleanupLine,
nextCleanupLine,
shorteningDescription,
} from '../activityRetention'
const NOW = new Date(2026, 9, 6, 15, 0)
function at(day: number, hour: number, minute: number) {
return new Date(2026, 9, day, hour, minute).toISOString()
}
function settings(overrides: Partial<WorkspaceSettings> = {}): WorkspaceSettings {
return {
version: 'v2.2.0-beta',
activity_retention_days: 90,
activity_last_cleanup_at: at(6, 3, 0),
activity_last_cleanup_removed: 142,
activity_next_cleanup_at: at(7, 3, 0),
...overrides,
}
}
beforeEach(() => {
localStorage.clear()
initializeLocale()
})
afterEach(() => {
localStorage.clear()
initializeLocale()
})
describe('recentWhenLabel follows the day', () => {
it.each([
[at(6, 14, 58), '2 min ago'],
[at(6, 12, 0), '3 h ago'],
[at(5, 14, 20), 'Yesterday, 14:20'],
[at(3, 14, 20), '3 Oct 2026, 14:20'],
])('%s reads %s', (iso, label) => {
expect(recentWhenLabel(iso, NOW)).toBe(label)
})
it('reads French labels in French', () => {
setLocale('fr')
expect(recentWhenLabel(at(5, 14, 20), NOW)).toBe('Hier, 14:20')
expect(recentWhenLabel(at(3, 14, 20), NOW)).toBe('3 oct. 2026, 14:20')
})
})
describe('retention periods', () => {
it('ranks 30 < 90 < 365 < Forever', () => {
expect(isShorter(30, 90)).toBe(true)
expect(isShorter(365, 0)).toBe(true)
expect(isShorter(0, 365)).toBe(false)
expect(isShorter(90, 90)).toBe(false)
})
})
describe('retention lines', () => {
it('reads the last cleanup with its count', () => {
expect(lastCleanupLine(settings())).toBe('6 Oct 2026, 03:00 · 142 events removed')
expect(lastCleanupLine(settings({ activity_last_cleanup_removed: 1 }))).toBe(
'6 Oct 2026, 03:00 · 1 event removed',
)
})
it('reads Never before the first cleanup and Not scheduled for Forever', () => {
expect(lastCleanupLine(settings({ activity_last_cleanup_at: null }))).toBe('Never')
expect(nextCleanupLine(settings({ activity_next_cleanup_at: null }))).toBe('Not scheduled')
expect(nextCleanupLine(settings())).toBe('7 Oct 2026, 03:00')
})
it('words the helper and the warning', () => {
expect(helperCopy(90)).toBe('Older events are removed automatically.')
expect(helperCopy(0)).toBe('Nothing is removed automatically. The log grows over time.')
expect(shorteningDescription(settings())).toBe(
"This happens at the next cleanup on 7 Oct 2026, 03:00. It can't be undone.",
)
})
})
@@ -0,0 +1,240 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { initializeLocale, setLocale, t } from '@/i18n'
import { messages, type MessageKey } from '@/i18n/messages'
import type { ActivityItem } from '@/lib/activity'
import {
SENTENCE_ACTIONS,
composeSentence,
sentenceTemplates,
sentenceText,
} from '../activitySentence'
function item(overrides: Partial<ActivityItem>): ActivityItem {
return {
id: 1,
created_at: '2026-10-03T12:00:00Z',
action: 'article.publish',
category: 'articles',
actor: { type: 'user', id: 3, name: 'Marta Alves' },
target: { type: 'article', id: '8', name: 'Spring release', exists: true },
outcome: 'success',
failure_reason: null,
security: false,
source: 'web',
ip: null,
user_agent: null,
params: {},
changes: [],
...overrides,
}
}
beforeEach(() => {
localStorage.clear()
initializeLocale()
})
afterEach(() => {
localStorage.clear()
initializeLocale()
})
describe('Sentences: English and French come from the same code and params', () => {
const cases: [string, Partial<ActivityItem>, string, string][] = [
[
'article.publish',
{},
'Published the article Spring release',
'Article publié : Spring release',
],
[
'article.publish failed',
{ outcome: 'failed' },
'Failed to publish the article Spring release',
"Échec de la publication de l'article Spring release",
],
[
'media.purge',
{ action: 'media.purge', target: null, params: { merged: 3, articles_updated: 5 } },
'Purged duplicate images: 3 merged, 5 articles updated',
'Doublons purgés : 3 fusionnées, 5 articles mis à jour',
],
[
'media.purge with failed groups',
{
action: 'media.purge',
target: null,
params: { merged: 3, articles_updated: 1, failed_groups: 1 },
},
'Purged duplicate images: 3 merged, 1 article updated, 1 group failed',
'Doublons purgés : 3 fusionnées, 1 article mis à jour, 1 groupe en échec',
],
[
'system.media_purge with several failed groups',
{
action: 'system.media_purge',
target: null,
params: { merged: 10, articles_updated: 12, failed_groups: 3 },
},
'Ran the scheduled duplicate purge: 10 merged, 12 articles updated, 3 groups failed',
'Purge automatique des doublons : 10 fusionnées, 12 articles mis à jour, 3 groupes en échec',
],
[
'system.activity_cleanup singular',
{ action: 'system.activity_cleanup', target: null, params: { removed: 1 } },
'Removed 1 old event from the activity log',
"1 ancien événement supprimé du journal d'activité",
],
[
'system.activity_cleanup plural',
{ action: 'system.activity_cleanup', target: null, params: { removed: 1284 } },
'Removed 1,284 old events from the activity log',
"1 284 anciens événements supprimés du journal d'activité",
],
[
'system.activity_retention_changed',
{
action: 'system.activity_retention_changed',
target: null,
params: { from: 90, to: 0 },
},
'Changed the activity retention from 90 days to Forever',
"Durée de conservation de l'activité modifiée : 90 jours → Toujours",
],
[
'system.restart_requested update',
{
action: 'system.restart_requested',
target: { type: 'release', id: 'v1.4.0', name: 'v1.4.0', exists: true },
params: { kind: 'update', tag: 'v1.4.0' },
},
'Restarted to apply the update v1.4.0',
'Redémarrage pour appliquer la mise à jour v1.4.0',
],
[
'system.restart_requested restart',
{ action: 'system.restart_requested', target: null, params: { kind: 'restart' } },
'Requested a restart',
'Redémarrage demandé',
],
[
'media.upload failed without a target',
{ action: 'media.upload', target: null, outcome: 'failed' },
'Failed to upload the image',
"Échec du téléversement de l'image",
],
[
'article.publish without a target',
{ target: null },
'Published the article',
'Article publié',
],
[
'auth.login_failed uses the actor name',
{
action: 'auth.login_failed',
target: null,
outcome: 'failed',
actor: { type: 'anonymous', id: null, name: 'alex@example.com' },
},
'Failed login attempt for alex@example.com',
'Échec de connexion pour alex@example.com',
],
[
'auth.login failed renders its success sentence',
{ action: 'auth.login', target: null, outcome: 'failed' },
'Logged in',
'Connexion',
],
[
'unknown action code',
{ action: 'article.archive' },
'article.archive',
'article.archive',
],
]
it.each(cases)('%s', (_, overrides, english, french) => {
const subject = item(overrides)
expect(sentenceText(subject)).toBe(english)
setLocale('fr')
expect(sentenceText(subject)).toBe(french)
})
it('inserts the target name untranslated as the semibold last segment', () => {
const segments = composeSentence(item({}))
expect(segments).toEqual([
{ text: 'Published the article ', strong: false },
{ text: 'Spring release', strong: true },
])
setLocale('fr')
expect(composeSentence(item({}))).toEqual([
{ text: 'Article publié : ', strong: false },
{ text: 'Spring release', strong: true },
])
})
it('keeps a target name that looks like a template slot', () => {
const subject = item({
target: { type: 'article', id: '1', name: '{0} {1}', exists: true },
})
expect(sentenceText(subject)).toBe('Published the article {0} {1}')
})
})
describe('The catalogue is complete in both languages', () => {
const templateKeys = SENTENCE_ACTIONS.flatMap((action) => {
const template = sentenceTemplates(action)!
return [template.success, ...(template.failed ? [template.failed] : [])]
})
it.each(Array.from(new Set(templateKeys)))('has a French entry for %s', (key) => {
expect(messages).toHaveProperty([key as MessageKey])
expect(messages[key as MessageKey]).not.toBe('')
expect(messages[key as MessageKey]).not.toBe(key)
})
it('covers every action the producers record', () => {
expect(SENTENCE_ACTIONS.sort()).toEqual(
[
'article.create',
'article.edit',
'article.publish',
'article.unpublish',
'article.delete',
'media.upload',
'media.rename',
'media.delete',
'media.merge',
'media.purge',
'system.media_purge',
'user.register',
'user.verify',
'user.update_profile',
'invite.create',
'invite.cancel',
'invite.renew',
'invite.accept',
'api_key.create',
'api_key.revoke',
'auth.login',
'auth.login_failed',
'auth.logout',
'auth.password_reset_requested',
'auth.password_reset_completed',
'system.update_checked',
'system.update_downloaded',
'system.restart_requested',
'system.activity_retention_changed',
'system.activity_cleanup',
].sort(),
)
})
it('formats a template with placeholders through t', () => {
expect(t('Revoked API key {0}', ['Mobile app'])).toBe('Revoked API key Mobile app')
})
})
+350
View File
@@ -0,0 +1,350 @@
import { formatNumber, t } from '@/i18n'
import { formatDateRange } from '@/lib/activityTime'
export const ACTIVITY_CATEGORIES = [
'articles',
'media',
'users',
'api_keys',
'auth',
'system',
] as const
export type ActivityCategory = (typeof ACTIVITY_CATEGORIES)[number]
export type ActivityOutcome = 'success' | 'failed'
export type OutcomeFilter = 'all' | ActivityOutcome
export type ActorType = 'user' | 'api_key' | 'system' | 'anonymous'
export type ActivitySource = 'web' | 'api_key' | 'system'
export type RangeKey = 'today' | '7d' | '30d' | 'custom'
export const PAGE_SIZES = [25, 50, 100] as const
export const DEFAULT_PAGE_SIZE = 25
export const DEFAULT_RANGE: RangeKey = '7d'
export const RANGE_KEYS: RangeKey[] = ['today', '7d', '30d', 'custom']
export const OUTCOME_FILTERS: OutcomeFilter[] = ['all', 'success', 'failed']
export interface ActivityActor {
type: ActorType
id: number | null
name: string
}
export interface ActivityTarget {
type: string
id: string
name: string
exists: boolean
}
export type ActivityChange =
| { field: string; kind: 'text'; before: string; after: string }
| { field: string; kind: 'body'; delta: number }
export interface ActivityItem {
id: number
created_at: string
action: string
category: ActivityCategory
actor: ActivityActor
target: ActivityTarget | null
outcome: ActivityOutcome
failure_reason: string | null
security: boolean
source: ActivitySource
ip: string | null
user_agent: string | null
params: Record<string, unknown>
changes: ActivityChange[]
}
export interface ActivityPage {
items: ActivityItem[]
total: number
page: number
size: number
}
export interface ActivitySummary {
total: number
previous_total: number
active_people: { users: number; api_keys: number }
failed: number
security: number
series: { total: number[]; active: number[]; failed: number[]; security: number[] }
}
export interface ActivityFilters {
q: string
actor: string
actorLabel: string
category: ActivityCategory | ''
outcome: OutcomeFilter
security: boolean
range: RangeKey
from: string
to: string
}
export type ChipKey = 'q' | 'actor' | 'category' | 'outcome' | 'security' | 'range'
export interface ActivityChip {
key: ChipKey
label: string
}
export function defaultFilters(): ActivityFilters {
return {
q: '',
actor: '',
actorLabel: '',
category: '',
outcome: 'all',
security: false,
range: DEFAULT_RANGE,
from: '',
to: '',
}
}
export function summaryRangeChanged(a: ActivityFilters, b: ActivityFilters) {
return a.range !== b.range || a.from !== b.from || a.to !== b.to
}
function filterParams(filters: ActivityFilters) {
const params = new URLSearchParams()
if (filters.q) params.set('q', filters.q)
if (filters.actor) params.set('actor', filters.actor)
if (filters.category) params.set('category', filters.category)
if (filters.outcome !== 'all') params.set('outcome', filters.outcome)
if (filters.security) params.set('security', 'true')
params.set('range', filters.range)
if (filters.range === 'custom') {
params.set('from', filters.from)
params.set('to', filters.to)
}
return params
}
export function listQuery(filters: ActivityFilters, page: number, size: number, withActor = true) {
const params = filterParams(withActor ? filters : { ...filters, actor: '' })
params.set('page', String(page))
params.set('size', String(size))
return params.toString()
}
export function exportQuery(filters: ActivityFilters) {
return filterParams(filters).toString()
}
export function summaryQuery(filters: ActivityFilters) {
const params = new URLSearchParams({ range: filters.range })
if (filters.range === 'custom') {
params.set('from', filters.from)
params.set('to', filters.to)
}
return params.toString()
}
export function categoryLabel(category: ActivityCategory) {
switch (category) {
case 'articles':
return t('Articles')
case 'media':
return t('Media')
case 'users':
return t('Users and invites')
case 'api_keys':
return t('API keys')
case 'auth':
return t('Authentication')
case 'system':
return t('System')
}
}
export function categoryIcon(category: ActivityCategory) {
switch (category) {
case 'articles':
return 'i-lucide-file-text'
case 'media':
return 'i-lucide-image'
case 'users':
return 'i-lucide-users'
case 'api_keys':
return 'i-lucide-key-round'
case 'auth':
return 'i-lucide-shield-check'
case 'system':
return 'i-lucide-server-cog'
}
}
export function outcomeFilterLabel(outcome: OutcomeFilter) {
switch (outcome) {
case 'all':
return t('All')
case 'success':
return t('Succeeded')
case 'failed':
return t('Failed')
}
}
export function outcomeOptions() {
return OUTCOME_FILTERS.map((value) => ({ value, label: outcomeFilterLabel(value) }))
}
export function outcomeBadge(outcome: ActivityOutcome) {
return outcome === 'success'
? { tone: 'ok' as const, label: t('Succeeded') }
: { tone: 'fail' as const, label: t('Failed') }
}
export function rowSurface(selected: boolean, security: boolean) {
return [selected ? 'bg-elevated' : 'bg-default', security ? 'shadow-flag' : '']
}
export function rangeLabel(range: RangeKey) {
switch (range) {
case 'today':
return t('Today')
case '7d':
return t('7 days')
case '30d':
return t('30 days')
case 'custom':
return t('Custom')
}
}
export function activeChips(filters: ActivityFilters): ActivityChip[] {
const chips: ActivityChip[] = []
if (filters.q) chips.push({ key: 'q', label: t('Search: {0}', [filters.q]) })
if (filters.actor) chips.push({ key: 'actor', label: t('Person: {0}', [filters.actorLabel]) })
if (filters.category)
chips.push({
key: 'category',
label: t('Category: {0}', [categoryLabel(filters.category)]),
})
if (filters.outcome !== 'all')
chips.push({
key: 'outcome',
label: t('Outcome: {0}', [outcomeFilterLabel(filters.outcome)]),
})
if (filters.security) chips.push({ key: 'security', label: t('Security events') })
if (filters.range !== DEFAULT_RANGE) {
const value =
filters.range === 'custom'
? formatDateRange(filters.from, filters.to)
: rangeLabel(filters.range)
chips.push({ key: 'range', label: t('Dates: {0}', [value]) })
}
return chips
}
export function clearChip(filters: ActivityFilters, key: ChipKey): ActivityFilters {
const defaults = defaultFilters()
switch (key) {
case 'q':
return { ...filters, q: '' }
case 'actor':
return { ...filters, actor: '', actorLabel: '' }
case 'category':
return { ...filters, category: '' }
case 'outcome':
return { ...filters, outcome: 'all' }
case 'security':
return { ...filters, security: false }
case 'range':
return { ...filters, range: defaults.range, from: '', to: '' }
}
}
export function initials(name: string) {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((word) => Array.from(word)[0])
.join('')
.toUpperCase()
}
export function sourceLabel(source: ActivitySource) {
switch (source) {
case 'web':
return t('Web app')
case 'api_key':
return t('API key')
case 'system':
return t('System task')
}
}
export function targetTypeLabel(type: string) {
switch (type) {
case 'article':
return t('Article')
case 'image':
return t('Image')
case 'user':
return t('User')
case 'invite':
return t('Invite')
case 'api_key':
return t('API key')
case 'release':
return t('Release')
default:
return type
}
}
export function targetLink(target: ActivityTarget | null) {
if (!target?.exists) return null
switch (target.type) {
case 'article':
return `/article/${target.id}/edit`
case 'image':
return '/media'
case 'user':
case 'invite':
return '/users'
case 'api_key':
return '/integration/api-keys'
default:
return null
}
}
export function fieldLabel(field: string) {
switch (field) {
case 'title':
return t('Title')
case 'online':
return t('Publish state')
case 'body':
return t('Body')
case 'filename':
return t('File name')
case 'display_name':
return t('Display name')
default:
return field
}
}
export function changeValue(field: string, value: string) {
if (field !== 'online') return value
if (value === 'false') return t('Draft')
if (value === 'true') return t('Published')
return value
}
export function bodyDeltaLabel(delta: number) {
const sign = delta < 0 ? '-' : '+'
const amount = `${sign}${formatNumber(Math.abs(delta))}`
return Math.abs(delta) === 1
? t('Body edited ({0} character)', [amount])
: t('Body edited ({0} characters)', [amount])
}
+62
View File
@@ -0,0 +1,62 @@
import { t } from '@/i18n'
import type { WorkspaceSettings } from '@/composables/useSettings'
import { localDayTime } from '@/lib/activityTime'
export const FOREVER_DAYS = 0
export const SAVED_VISIBLE_MS = 2000
const RANKED_PERIODS = [30, 90, 365, FOREVER_DAYS]
export function retentionOptions() {
return RANKED_PERIODS.map((value) => ({ value, label: periodLabel(value) }))
}
export function periodLabel(days: number) {
switch (days) {
case 30:
return t('30 days')
case 90:
return t('90 days')
case 365:
return t('365 days')
default:
return t('Forever')
}
}
export function periodRank(days: number) {
return RANKED_PERIODS.indexOf(days)
}
export function isShorter(candidate: number, saved: number) {
return periodRank(candidate) < periodRank(saved)
}
export function helperCopy(days: number) {
return days === FOREVER_DAYS
? t('Nothing is removed automatically. The log grows over time.')
: t('Older events are removed automatically.')
}
export function lastCleanupLine(settings: WorkspaceSettings) {
if (!settings.activity_last_cleanup_at) {
return t('Never')
}
const removed = settings.activity_last_cleanup_removed
const count = t(removed === 1 ? '{0} event removed' : '{0} events removed', [removed])
return `${localDayTime(settings.activity_last_cleanup_at)} · ${count}`
}
export function nextCleanupLine(settings: WorkspaceSettings) {
return settings.activity_next_cleanup_at
? localDayTime(settings.activity_next_cleanup_at)
: t('Not scheduled')
}
export function shorteningDescription(settings: WorkspaceSettings) {
return settings.activity_next_cleanup_at
? t("This happens at the next cleanup on {0}. It can't be undone.", [
localDayTime(settings.activity_next_cleanup_at),
])
: t("Older events will be removed at the next cleanup. It can't be undone.")
}
+209
View File
@@ -0,0 +1,209 @@
import { formatNumber, t } from '@/i18n'
import type { MessageKey } from '@/i18n/messages'
import type { ActivityItem } from '@/lib/activity'
export interface SentenceSegment {
text: string
strong: boolean
}
interface Template {
success: MessageKey
failed?: MessageKey
}
const TARGET_SLOT = '\u0000'
const DROPPED_SLOT = new RegExp(`[\\s\\u00a0]*:?[\\s\\u00a0]*${TARGET_SLOT}`)
const PURGE_ACTIONS = ['media.purge', 'system.media_purge']
const CATALOGUE: Record<string, Template> = {
'article.create': {
success: 'Created the article {0}',
failed: 'Failed to create the article {0}',
},
'article.edit': { success: 'Edited the article {0}', failed: 'Failed to edit the article {0}' },
'article.publish': {
success: 'Published the article {0}',
failed: 'Failed to publish the article {0}',
},
'article.unpublish': {
success: 'Unpublished the article {0}',
failed: 'Failed to unpublish the article {0}',
},
'article.delete': {
success: 'Deleted the article {0}',
failed: 'Failed to delete the article {0}',
},
'media.upload': {
success: 'Uploaded the image {0}',
failed: 'Failed to upload the image {0}',
},
'media.rename': {
success: 'Renamed the image {0}',
failed: 'Failed to rename the image {0}',
},
'media.delete': { success: 'Deleted the image {0}', failed: 'Failed to delete the image {0}' },
'media.merge': {
success: 'Merged duplicates into the image {0}',
failed: 'Failed to merge duplicates into the image {0}',
},
'media.purge': {
success: 'Purged duplicate images: {0}',
failed: 'Failed to purge duplicate images',
},
'system.media_purge': {
success: 'Ran the scheduled duplicate purge: {0}',
failed: 'Failed to run the scheduled duplicate purge',
},
'user.register': {
success: 'Registered the account {0}',
failed: 'Failed to register the account {0}',
},
'user.verify': {
success: 'Verified the account {0}',
failed: 'Failed to verify the account {0}',
},
'user.update_profile': {
success: 'Updated the profile of {0}',
failed: 'Failed to update the profile of {0}',
},
'invite.create': { success: 'Invited {0}', failed: 'Failed to invite {0}' },
'invite.cancel': {
success: 'Cancelled the invite for {0}',
failed: 'Failed to cancel the invite for {0}',
},
'invite.renew': {
success: 'Renewed the invite for {0}',
failed: 'Failed to renew the invite for {0}',
},
'invite.accept': {
success: 'Accepted the invite for {0}',
failed: 'Failed to accept the invite for {0}',
},
'api_key.create': {
success: 'Created API key {0}',
failed: 'Failed to create API key {0}',
},
'api_key.revoke': {
success: 'Revoked API key {0}',
failed: 'Failed to revoke API key {0}',
},
'auth.login': { success: 'Logged in' },
'auth.login_failed': {
success: 'Failed login attempt for {0}',
failed: 'Failed login attempt for {0}',
},
'auth.logout': { success: 'Logged out' },
'auth.password_reset_requested': { success: 'Requested a password reset' },
'auth.password_reset_completed': { success: 'Reset the password' },
'system.update_checked': {
success: 'Checked for updates',
failed: 'Failed to check for updates',
},
'system.update_downloaded': {
success: 'Downloaded the update {0}',
failed: 'Failed to download the update {0}',
},
'system.restart_requested': { success: 'Requested a restart', failed: 'Failed to restart' },
'system.activity_retention_changed': {
success: 'Changed the activity retention from {0} to {1}',
},
'system.activity_cleanup': { success: 'Removed {0} old events from the activity log' },
}
function count(value: unknown) {
return typeof value === 'number' && Number.isFinite(value) ? value : 0
}
function purgeSummary(params: Record<string, unknown>) {
const articles = count(params.articles_updated)
const failedGroups = count(params.failed_groups)
const parts = [
t('{0} merged', [formatNumber(count(params.merged))]),
articles === 1
? t('1 article updated')
: t('{0} articles updated', [formatNumber(articles)]),
]
if (failedGroups > 0) {
parts.push(
failedGroups === 1
? t('1 group failed')
: t('{0} groups failed', [formatNumber(failedGroups)]),
)
}
return parts.join(', ')
}
function retentionDays(value: unknown) {
const days = count(value)
return days === 0 ? t('Forever') : t('{0} days', [formatNumber(days)])
}
function resolveTemplate(item: ActivityItem): MessageKey | null {
const template = CATALOGUE[item.action]
if (!template) return null
if (item.action === 'system.restart_requested' && item.outcome === 'success') {
return item.params.kind === 'update'
? 'Restarted to apply the update {0}'
: 'Requested a restart'
}
if (item.outcome === 'failed') return template.failed ?? template.success
return template.success
}
function slotValues(item: ActivityItem, template: MessageKey) {
if (PURGE_ACTIONS.includes(item.action) && item.outcome === 'success') {
return [purgeSummary(item.params)]
}
if (item.action === 'system.activity_retention_changed') {
return [retentionDays(item.params.from), retentionDays(item.params.to)]
}
if (item.action === 'system.activity_cleanup') {
return [formatNumber(count(item.params.removed))]
}
return template.includes('{0}') ? [TARGET_SLOT] : []
}
function targetName(item: ActivityItem) {
if (item.action === 'auth.login_failed') return item.actor.name
return item.target?.name ?? ''
}
function usesTargetSlot(values: string[]) {
return values.length === 1 && values[0] === TARGET_SLOT
}
export function composeSentence(item: ActivityItem): SentenceSegment[] {
const template = resolveTemplate(item)
if (template === null) return [{ text: item.action, strong: false }]
if (item.action === 'system.activity_cleanup' && count(item.params.removed) === 1) {
return [{ text: t('Removed 1 old event from the activity log'), strong: false }]
}
const values = slotValues(item, template)
const name = targetName(item)
const rendered = t(template, values)
if (!usesTargetSlot(values)) return [{ text: rendered, strong: false }]
if (!name) return [{ text: rendered.replace(DROPPED_SLOT, ''), strong: false }]
const [before, after] = rendered.split(TARGET_SLOT)
const segments: SentenceSegment[] = [
{ text: before ?? '', strong: false },
{ text: name, strong: true },
]
if (after) segments.push({ text: after, strong: false })
return segments.filter((segment) => segment.text !== '')
}
export function sentenceText(item: ActivityItem) {
return composeSentence(item)
.map((segment) => segment.text)
.join('')
}
export const SENTENCE_ACTIONS = Object.keys(CATALOGUE)
export function sentenceTemplates(action: string) {
return CATALOGUE[action]
}
+109
View File
@@ -0,0 +1,109 @@
import { formatNumber, t } from '@/i18n'
import type { ActivitySummary, RangeKey } from '@/lib/activity'
export type StripKey = 'total' | 'active' | 'failed' | 'security'
export type StripTone = 'neutral' | 'error' | 'warning' | 'calm'
export interface StripCard {
key: StripKey
label: string
value: string
subline: string
icon: string | null
tone: StripTone
series: number[]
}
export const STRIP_KEYS: StripKey[] = ['total', 'active', 'failed', 'security']
export const BAR_COUNT = 7
export const MIN_BAR_HEIGHT = 2
function percent(part: number, whole: number) {
return Math.round((Math.abs(part - whole) / whole) * 100)
}
function totalSubline(summary: ActivitySummary, range: RangeKey) {
if (summary.total === 0) return { text: t('No data yet'), icon: null }
if (summary.previous_total === 0) return { text: t('No earlier data'), icon: null }
if (summary.total === summary.previous_total) return { text: t('No change'), icon: null }
const value = formatNumber(percent(summary.total, summary.previous_total))
const text =
range === 'today'
? t('{0}% vs previous day', [value])
: range === '30d'
? t('{0}% vs previous 30 days', [value])
: range === 'custom'
? t('{0}% vs previous period', [value])
: t('{0}% vs previous 7 days', [value])
const icon =
summary.total >= summary.previous_total ? 'i-lucide-trending-up' : 'i-lucide-trending-down'
return { text, icon }
}
function peopleSubline(summary: ActivitySummary) {
if (summary.total === 0) return t('No data yet')
const { users, api_keys: keys } = summary.active_people
const people = users === 1 ? t('1 person') : t('{0} people', [formatNumber(users)])
const apiKeys = keys === 1 ? t('1 API key') : t('{0} API keys', [formatNumber(keys)])
return `${people}, ${apiKeys}`
}
function failedSubline(summary: ActivitySummary) {
const ratio = (summary.failed / summary.total) * 100
return t('{0}% of all actions', [
formatNumber(ratio, { minimumFractionDigits: 1, maximumFractionDigits: 1 }),
])
}
function flaggedSubline(count: number) {
return count === 1 ? t('1 flagged') : t('{0} flagged', [formatNumber(count)])
}
export function stripCards(summary: ActivitySummary, range: RangeKey): StripCard[] {
const total = totalSubline(summary, range)
const failedCalm = summary.failed === 0
const securityCalm = summary.security === 0
return [
{
key: 'total',
label: t('Total actions'),
value: formatNumber(summary.total),
subline: total.text,
icon: total.icon,
tone: 'neutral',
series: summary.series.total,
},
{
key: 'active',
label: t('Active people'),
value: formatNumber(summary.active_people.users + summary.active_people.api_keys),
subline: peopleSubline(summary),
icon: null,
tone: 'neutral',
series: summary.series.active,
},
{
key: 'failed',
label: t('Failed actions'),
value: formatNumber(summary.failed),
subline: failedCalm ? t('Nothing failed') : failedSubline(summary),
icon: failedCalm ? 'i-lucide-circle-check' : 'i-lucide-circle-x',
tone: failedCalm ? 'calm' : 'error',
series: summary.series.failed,
},
{
key: 'security',
label: t('Security events'),
value: formatNumber(summary.security),
subline: securityCalm ? t('Nothing to flag') : flaggedSubline(summary.security),
icon: securityCalm ? 'i-lucide-circle-check' : 'i-lucide-shield-alert',
tone: securityCalm ? 'calm' : 'warning',
series: summary.series.security,
},
]
}
export function barHeights(series: number[], height: number) {
const peak = Math.max(...series, 1)
return series.map((value) => Math.max(MIN_BAR_HEIGHT, Math.round((value / peak) * height)))
}
+125
View File
@@ -0,0 +1,125 @@
import { locale, t } from '@/i18n'
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
const MINUTE_MS = 60 * 1000
const HOUR_MS = 60 * MINUTE_MS
const ISO_DAY = /^(\d{4})-(\d{2})-(\d{2})$/
export interface DayGroup<T> {
key: string
label: string
items: T[]
}
function pad(value: number) {
return String(value).padStart(2, '0')
}
function monthShort(date: Date, timeZone?: 'UTC') {
if (locale.value === 'fr') {
return new Intl.DateTimeFormat('fr', { month: 'short', timeZone }).format(date)
}
return MONTHS[timeZone ? date.getUTCMonth() : date.getMonth()]
}
export function formatAbsoluteDay(date: Date, timeZone?: 'UTC') {
const day = timeZone ? date.getUTCDate() : date.getDate()
const year = timeZone ? date.getUTCFullYear() : date.getFullYear()
return `${day} ${monthShort(date, timeZone)} ${year}`
}
function clock(date: Date, withSeconds: boolean, utc: boolean) {
const hours = utc ? date.getUTCHours() : date.getHours()
const minutes = utc ? date.getUTCMinutes() : date.getMinutes()
const seconds = utc ? date.getUTCSeconds() : date.getSeconds()
const base = `${pad(hours)}:${pad(minutes)}`
return withSeconds ? `${base}:${pad(seconds)}` : base
}
export function localDayKey(date: Date) {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
function startOfDay(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
}
export function dayLabel(iso: string, now: Date = new Date()) {
const date = new Date(iso)
const today = startOfDay(now)
const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1)
const key = localDayKey(date)
if (key === localDayKey(today)) return t('Today')
if (key === localDayKey(yesterday)) return t('Yesterday')
return formatAbsoluteDay(date)
}
export function whenLabel(iso: string, now: Date = new Date()) {
const date = new Date(iso)
if (localDayKey(date) !== localDayKey(now)) return clock(date, false, false)
const elapsed = Math.max(0, now.getTime() - date.getTime())
if (elapsed < HOUR_MS) {
return t('{0} min ago', [Math.max(1, Math.floor(elapsed / MINUTE_MS))])
}
return t('{0} h ago', [Math.floor(elapsed / HOUR_MS)])
}
export function recentWhenLabel(iso: string, now: Date = new Date()) {
const date = new Date(iso)
const key = localDayKey(date)
if (key === localDayKey(now)) return whenLabel(iso, now)
const today = startOfDay(now)
const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1)
if (key === localDayKey(yesterday)) return `${t('Yesterday')}, ${clock(date, false, false)}`
return localDayTime(iso)
}
export function localDayTime(iso: string) {
const date = new Date(iso)
return `${formatAbsoluteDay(date)}, ${clock(date, false, false)}`
}
export function fullLocalTime(iso: string) {
const date = new Date(iso)
return `${formatAbsoluteDay(date)}, ${clock(date, true, false)}`
}
export function fullUtcTime(iso: string) {
const date = new Date(iso)
return `${formatAbsoluteDay(date, 'UTC')}, ${clock(date, true, true)}`
}
export function groupByDay<T extends { created_at: string }>(
items: T[],
now: Date = new Date(),
): DayGroup<T>[] {
const groups: DayGroup<T>[] = []
for (const item of items) {
const key = localDayKey(new Date(item.created_at))
const last = groups[groups.length - 1]
if (last && last.key === key) {
last.items.push(item)
continue
}
groups.push({ key, label: dayLabel(item.created_at, now), items: [item] })
}
return groups
}
function parseIsoDay(value: string) {
const match = ISO_DAY.exec(value)
if (!match) return null
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), 12)
}
export function formatDateRange(from: string, to: string) {
const start = parseIsoDay(from)
const end = parseIsoDay(to)
if (!start || !end) return ''
const sameYear = start.getFullYear() === end.getFullYear()
const sameMonth = sameYear && start.getMonth() === end.getMonth()
if (sameMonth && start.getDate() === end.getDate()) return formatAbsoluteDay(end)
if (sameMonth) return `${start.getDate()} – ${formatAbsoluteDay(end)}`
if (sameYear) return `${start.getDate()} ${monthShort(start)} – ${formatAbsoluteDay(end)}`
return `${formatAbsoluteDay(start)} – ${formatAbsoluteDay(end)}`
}
+15
View File
@@ -0,0 +1,15 @@
const SEGMENT_BUTTON =
'h-10.5 gap-1.5 px-3 text-sm ring-0 md:h-7.5 md:px-2.5 rounded-none whitespace-nowrap'
const SEGMENT_SELECTED =
'bg-secondary-200 font-medium text-primary-900 hover:bg-secondary-200 dark:bg-secondary-300 dark:hover:bg-secondary-300'
const SEGMENT_IDLE = 'bg-default font-normal text-toned'
export function segmentClass(selected: boolean, block = false) {
return [
SEGMENT_BUTTON,
selected ? SEGMENT_SELECTED : SEGMENT_IDLE,
block ? 'flex-1 justify-center' : '',
]
}
export const SEGMENT_GROUP = 'flex shrink-0 border border-accented'
+5
View File
@@ -81,6 +81,11 @@ const router = createRouter({
component: () => import('@/views/UsersView.vue'), component: () => import('@/views/UsersView.vue'),
meta: { auth: 'verified', title: 'Users' }, meta: { auth: 'verified', title: 'Users' },
}, },
{
path: '/activity',
component: () => import('@/views/ActivityView.vue'),
meta: { auth: 'verified', title: 'Activity' },
},
{ {
path: '/profile', path: '/profile',
component: () => import('@/views/ProfileView.vue'), component: () => import('@/views/ProfileView.vue'),
+296
View File
@@ -0,0 +1,296 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useMediaQuery } from '@vueuse/core'
import ActivityCardList from '@/components/ActivityCardList.vue'
import ActivityChips from '@/components/ActivityChips.vue'
import ActivityDetailsPanel from '@/components/ActivityDetailsPanel.vue'
import ActivityEmptyPanel from '@/components/ActivityEmptyPanel.vue'
import ActivityFilterBar from '@/components/ActivityFilterBar.vue'
import ActivityFiltersSheet from '@/components/ActivityFiltersSheet.vue'
import ActivityPager from '@/components/ActivityPager.vue'
import ActivitySummaryStrip from '@/components/ActivitySummaryStrip.vue'
import ActivityTable from '@/components/ActivityTable.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import { useActivityPage } from '@/composables/useActivityPage'
import { useSessionStore } from '@/stores/session'
import { defaultFilters, type ActivityFilters } from '@/lib/activity'
import type { StripKey } from '@/lib/activitySummary'
const DESKTOP_QUERY = '(min-width: 48rem)'
const router = useRouter()
const session = useSessionStore()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const activity = useActivityPage()
const {
filters,
items,
total,
page,
size,
listState,
summary,
summaryState,
people,
chips,
isFullView,
isEmpty,
exporting,
exportDisabled,
showListSkeleton,
showSummarySkeleton,
selectedId,
selectedItem,
position,
canPrevious,
canNext,
} = activity
const sheetOpen = ref(false)
let started = false
let opener: HTMLElement | null = null
const sessionReady = computed(() => session.status === 'ready' && !!session.session?.verified)
const title = computed(() =>
isFullView.value || session.status !== 'ready' ? t('Activity') : t('My activity'),
)
const subtitle = computed(() =>
isFullView.value
? t('Who did what, and when, across your site.')
: t("Everything you've done on this site."),
)
const forbidden = computed(() => listState.value === 'forbidden')
const showStrip = computed(
() =>
isFullView.value &&
!forbidden.value &&
((summaryState.value !== 'failed' && summary.value !== null) || showSummarySkeleton.value),
)
const showRows = computed(
() =>
listState.value === 'ready' ||
(listState.value === 'loading' && (showListSkeleton.value || items.value.length > 0)),
)
const showPager = computed(
() => showRows.value && !showListSkeleton.value && !isEmpty.value && items.value.length > 0,
)
const emptyIsFiltered = computed(() => chips.value.length > 0)
watch(
sessionReady,
(ready) => {
if (ready && !started) {
started = true
activity.start()
}
},
{ immediate: true },
)
function onFilters(next: ActivityFilters) {
activity.applyFilters(next)
}
function onStripCard(key: StripKey) {
if (key === 'total') activity.applyFilters(defaultFilters(), true)
else if (key === 'failed') activity.applyFilters({ ...filters.value, outcome: 'failed' })
else if (key === 'security') activity.applyFilters({ ...filters.value, security: true })
}
function onOpen(item: { id: number }, element: HTMLElement) {
opener = element
activity.openDetails(item.id)
}
async function onClose() {
const id = selectedId.value
activity.closeDetails()
await nextTick()
const row = document.querySelector<HTMLElement>(`[data-activity-id="${id}"]`)
;(row ?? opener)?.focus()
}
function goHome() {
router.push('/home')
}
</script>
<template>
<DashboardShell compact-padding :mobile-title="title">
<template #breadcrumb>
<UBreadcrumb
:items="[{ label: title }]"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
</template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-45 flex-1 flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">{{ title }}</h1>
<p class="text-muted">{{ subtitle }}</p>
</div>
<UButton
v-if="isFullView && !forbidden"
color="neutral"
variant="outline"
icon="i-lucide-download"
:disabled="exportDisabled"
class="h-11 border border-accented bg-default px-3 text-sm text-highlighted ring-0 md:h-8"
@click="activity.runExport"
>
{{ exporting ? t('Preparing file…') : t('Export CSV') }}
</UButton>
</div>
<ActivityEmptyPanel
v-if="forbidden"
icon="i-lucide-lock"
:title="t('You can\'t view this activity')"
:description="
t(
'Your role doesn\'t include this page. Ask an owner or admin if you need access.',
)
"
:action-label="t('Back to home')"
@action="goHome"
/>
<template v-else>
<ActivitySummaryStrip
v-if="showStrip"
:summary="summary"
:range="filters.range"
:loading="showSummarySkeleton"
:is-desktop="isDesktop"
:outcome="filters.outcome"
:security="filters.security"
@apply-filter="onStripCard"
/>
<ActivityFilterBar
:filters="filters"
:people="people"
:can-filter-people="isFullView"
:is-desktop="isDesktop"
:chip-count="chips.length"
@update:filters="onFilters"
@open-people="activity.ensurePeople"
@open-sheet="sheetOpen = true"
/>
<ActivityChips
:chips="chips"
@remove="activity.removeChip"
@clear="activity.clearAll"
/>
<UAlert
v-if="listState === 'error'"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t load activity')"
:description="t('Check your connection and try again.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
class="h-11 border border-accented bg-default px-3 text-xs text-highlighted ring-0 md:h-7"
@click="activity.retry"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<ActivityEmptyPanel
v-else-if="isEmpty && !emptyIsFiltered"
icon="i-lucide-history"
:title="t('No activity yet')"
:description="
isFullView
? t('Actions on articles, media, users and API keys will appear here.')
: t('Your actions will appear here once you start working on the site.')
"
/>
<ActivityEmptyPanel
v-else-if="isEmpty"
icon="i-lucide-search-x"
:title="t('No activity matches these filters')"
:description="t('Try a different search or a wider date range.')"
:action-label="t('Clear filters')"
@action="activity.clearAll"
/>
<template v-else-if="showRows">
<ActivityTable
v-if="isDesktop"
:items="items"
:loading="showListSkeleton"
:selected-id="selectedId"
:show-actor="isFullView"
@open="onOpen"
/>
<ActivityCardList
v-else
:items="items"
:loading="showListSkeleton"
:selected-id="selectedId"
:show-actor="isFullView"
@open="onOpen"
/>
</template>
<ActivityPager
v-if="showPager"
:page="page"
:page-count="activity.pageCount.value"
:total="total"
:size="size"
:from="activity.rangeStart.value"
:to="activity.rangeEnd.value"
:is-desktop="isDesktop"
@update:page="activity.goToPage"
@update:size="activity.setSize"
/>
</template>
</div>
<ActivityDetailsPanel
:item="selectedItem"
:position="position"
:total="total"
:can-previous="canPrevious"
:can-next="canNext"
:is-desktop="isDesktop"
@previous="activity.previous"
@next="activity.next"
@close="onClose"
/>
<ActivityFiltersSheet
v-if="!isDesktop && !forbidden"
v-model:open="sheetOpen"
:filters="filters"
:people="people"
:can-filter-people="isFullView"
:chip-count="chips.length"
:total="total"
@update:filters="onFilters"
@open-people="activity.ensurePeople"
@clear="activity.clearAll"
/>
</DashboardShell>
</template>
+30 -7
View File
@@ -7,6 +7,7 @@ import { useLocalizedToast } from '@/i18n/presentation'
import DashboardShell from '@/components/DashboardShell.vue' import DashboardShell from '@/components/DashboardShell.vue'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue' import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import NewArticleModal from '@/components/NewArticleModal.vue' import NewArticleModal from '@/components/NewArticleModal.vue'
import RecentActivityCard from '@/components/RecentActivityCard.vue'
import UpdateBanner from '@/components/UpdateBanner.vue' import UpdateBanner from '@/components/UpdateBanner.vue'
import { useArticles, type Article } from '@/composables/useArticles' import { useArticles, type Article } from '@/composables/useArticles'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
@@ -180,7 +181,7 @@ onMounted(loadArticles)
/> />
</template> </template>
<div class="flex max-w-244 flex-col gap-5 lg:gap-7"> <div class="flex flex-col gap-5 lg:gap-7">
<div class="flex flex-wrap items-end gap-4"> <div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-50 flex-1 flex-col gap-1"> <div class="flex min-w-50 flex-1 flex-col gap-1">
<h1 data-testid="greeting" class="text-2xl font-semibold text-highlighted"> <h1 data-testid="greeting" class="text-2xl font-semibold text-highlighted">
@@ -205,7 +206,14 @@ onMounted(loadArticles)
@dismiss="dismissBanner" @dismiss="dismissBanner"
/> />
<template v-if="(loadState === 'loading' && showSkeleton) || loadState === 'default'"> <div
class="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_--spacing(105)] lg:items-start"
>
<RecentActivityCard class="lg:order-2" />
<div class="flex min-w-0 flex-col gap-5 lg:order-1 lg:gap-7">
<template
v-if="(loadState === 'loading' && showSkeleton) || loadState === 'default'"
>
<div class="grid grid-cols-3 gap-3"> <div class="grid grid-cols-3 gap-3">
<div <div
v-for="tile in statTiles" v-for="tile in statTiles"
@@ -231,7 +239,10 @@ onMounted(loadArticles)
<h2 class="text-lg font-semibold text-highlighted"> <h2 class="text-lg font-semibold text-highlighted">
{{ t('Recently updated') }} {{ t('Recently updated') }}
</h2> </h2>
<RouterLink to="/article" class="text-toned underline underline-offset-3"> <RouterLink
to="/article"
class="text-toned underline underline-offset-3"
>
{{ t('All articles') }} {{ t('All articles') }}
</RouterLink> </RouterLink>
</div> </div>
@@ -245,17 +256,25 @@ onMounted(loadArticles)
:key="n" :key="n"
class="flex min-h-13 items-center gap-3 border-t border-muted px-3 py-2 lg:min-h-11" class="flex min-h-13 items-center gap-3 border-t border-muted px-3 py-2 lg:min-h-11"
> >
<USkeleton class="h-3 max-w-[60%] flex-1 rounded-none bg-accented" /> <USkeleton
class="h-3 max-w-[60%] flex-1 rounded-none bg-accented"
/>
<USkeleton class="h-5 w-14 rounded-none bg-accented" /> <USkeleton class="h-5 w-14 rounded-none bg-accented" />
</div> </div>
</div> </div>
<ul v-else data-testid="recent-list" class="border border-default bg-default"> <ul
v-else
data-testid="recent-list"
class="border border-default bg-default"
>
<li v-for="article in recentArticles" :key="article.id"> <li v-for="article in recentArticles" :key="article.id">
<RouterLink <RouterLink
:to="`/article/${article.id}/edit`" :to="`/article/${article.id}/edit`"
class="flex min-h-13 items-center gap-3 border-t border-muted px-3 py-2 lg:min-h-11" class="flex min-h-13 items-center gap-3 border-t border-muted px-3 py-2 lg:min-h-11"
> >
<span class="min-w-0 flex-1 truncate font-medium text-highlighted"> <span
class="min-w-0 flex-1 truncate font-medium text-highlighted"
>
{{ article.title }} {{ article.title }}
</span> </span>
<ArticleStatusBadge :online="article.is_online" /> <ArticleStatusBadge :online="article.is_online" />
@@ -274,7 +293,9 @@ onMounted(loadArticles)
icon="i-lucide-file-text" icon="i-lucide-file-text"
:title="t('Write your first article')" :title="t('Write your first article')"
:description=" :description="
t('It starts as a draft, so nothing appears on your site until you publish it.') t(
'It starts as a draft, so nothing appears on your site until you publish it.',
)
" "
:ui="{ :ui="{
root: 'gap-3 rounded-none border border-default px-6 py-12 ring-0 sm:px-6 sm:py-12 lg:px-6 lg:py-12', root: 'gap-3 rounded-none border border-default px-6 py-12 ring-0 sm:px-6 sm:py-12 lg:px-6 lg:py-12',
@@ -348,6 +369,8 @@ onMounted(loadArticles)
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
<NewArticleModal v-model:open="newArticleModalOpen" @created="onArticleCreated" /> <NewArticleModal v-model:open="newArticleModalOpen" @created="onArticleCreated" />
</DashboardShell> </DashboardShell>
+15 -4
View File
@@ -7,10 +7,11 @@ import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue' import DashboardShell from '@/components/DashboardShell.vue'
import MediaCleanupCard from '@/components/MediaCleanupCard.vue' import MediaCleanupCard from '@/components/MediaCleanupCard.vue'
import RestartOverlay from '@/components/RestartOverlay.vue' import RestartOverlay from '@/components/RestartOverlay.vue'
import RetentionCard from '@/components/RetentionCard.vue'
import UpdatesCard from '@/components/UpdatesCard.vue' import UpdatesCard from '@/components/UpdatesCard.vue'
import { ApiError } from '@/composables/useApi' import { ApiError } from '@/composables/useApi'
import { useRetryAfter } from '@/composables/useRetryAfter' import { useRetryAfter } from '@/composables/useRetryAfter'
import { useSettings } from '@/composables/useSettings' import { useSettings, type WorkspaceSettings } from '@/composables/useSettings'
import type { RestartKind, UpdateStatus } from '@/composables/useUpdate' import type { RestartKind, UpdateStatus } from '@/composables/useUpdate'
import { reloadPage } from '@/lib/reloadPage' import { reloadPage } from '@/lib/reloadPage'
import { displayVersion, isCheckDue } from '@/lib/updateFormat' import { displayVersion, isCheckDue } from '@/lib/updateFormat'
@@ -52,7 +53,9 @@ const retry = useRetryAfter()
const updatesCard = ref<InstanceType<typeof UpdatesCard> | null>(null) const updatesCard = ref<InstanceType<typeof UpdatesCard> | null>(null)
const loadState = ref<LoadState>('loading') const loadState = ref<LoadState>('loading')
const version = ref('') const settings = ref<WorkspaceSettings | null>(null)
const version = computed(() => settings.value?.version ?? '')
const isOwner = computed(() => session.session?.role === 'owner')
const showSkeleton = ref(false) const showSkeleton = ref(false)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null let skeletonTimer: ReturnType<typeof setTimeout> | null = null
@@ -76,8 +79,7 @@ async function loadSettings() {
}, SKELETON_DELAY_MS) }, SKELETON_DELAY_MS)
try { try {
const settings = await getSettings() settings.value = await getSettings()
version.value = settings.version
loadState.value = 'ready' loadState.value = 'ready'
} catch (error) { } catch (error) {
if (!isHandledGlobally(error)) { if (!isHandledGlobally(error)) {
@@ -458,6 +460,15 @@ onBeforeUnmount(() => {
/> />
<MediaCleanupCard /> <MediaCleanupCard />
<RetentionCard
v-if="isOwner"
:settings="settings"
:load-state="loadState"
:show-skeleton="showSkeleton"
@saved="settings = $event"
@reload="loadSettings"
/>
</div> </div>
</DashboardShell> </DashboardShell>
</template> </template>
@@ -0,0 +1,488 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { ApiError, useApi } from '@/composables/useApi'
import { initializeLocale } from '@/i18n'
import ActivityPager from '@/components/ActivityPager.vue'
import type { ActivityChange, ActivityItem } from '@/lib/activity'
import {
LIST_PATH,
callsTo,
click,
expectIcon,
findButton,
harness,
mountActivity,
queryObject,
rows,
textOf,
unmountActivity,
} from './activityHarness'
import {
activityItems,
anonymous,
makeItem,
pageOf,
system,
target,
LONG_VALUE,
} from './activityFixtures'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: vi.fn() }) }
})
beforeEach(() => {
localStorage.clear()
initializeLocale()
})
afterEach(async () => {
vi.useRealTimers()
unmountActivity()
vi.mocked(useApi).mockReset()
await router.push('/login')
})
function panel() {
return document.body.querySelector<HTMLElement>('[role="dialog"]')
}
function details() {
return document.body.querySelector<HTMLElement>('[data-testid="activity-details"]')
}
async function openRow(index: number) {
const row = rows()[index]!
row.focus()
row.click()
await flushPromises()
await vi.waitFor(() => expect(details()).toBeTruthy())
return row
}
async function openWith(item: ActivityItem, desktop = true) {
await mountActivity({ desktop, list: () => pageOf([item]) })
await openRow(0)
}
function pressEscape() {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
}
describe('Details: a row opens the slide-over and focus returns on close', () => {
it('opens from a click, marks the row selected and closes with Escape', async () => {
await mountActivity()
const row = await openRow(1)
expect(textOf(panel()?.querySelector('[data-slot="title"], h2'))).toContain(
'Activity details',
)
expect(row.getAttribute('aria-selected')).toBe('true')
expect(row.className).toContain('bg-elevated')
expect(textOf(panel())).toContain('Event 2 of 1,284')
pressEscape()
await flushPromises()
await vi.waitFor(() => expect(details()).toBeNull())
expect(document.activeElement).toBe(row)
expect(row.getAttribute('aria-selected')).toBe('false')
})
it('opens from Enter on a focused row and closes with the close button', async () => {
await mountActivity()
const row = rows()[0]!
row.focus()
row.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
await flushPromises()
await vi.waitFor(() => expect(details()).toBeTruthy())
await click(panel()!.querySelector('button[aria-label="Close"]'))
await vi.waitFor(() => expect(details()).toBeNull())
expect(document.activeElement).toBe(row)
})
it('closes when the scrim is pressed', async () => {
await mountActivity()
const row = await openRow(0)
document.body.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
await vi.waitFor(() => expect(details()).toBeNull())
expect(document.activeElement).toBe(row)
})
})
describe('Details: content', () => {
it('shows the edit with a diff and never the body text', async () => {
await mountActivity()
await openRow(1)
expect(textOf(details()!.querySelector('[data-testid="activity-details-sentence"]'))).toBe(
'Edited the article Why we reuse original floorboards',
)
const changes = Array.from(details()!.querySelectorAll('[data-testid="activity-change"]'))
expect(changes).toHaveLength(2)
expect(textOf(changes[0])).toContain('Title')
expect(textOf(changes[0]!.querySelector('[data-testid="activity-before"]'))).toBe(
'Why we reuse floorboards',
)
expect(textOf(changes[0]!.querySelector('[data-testid="activity-after"]'))).toBe(
'Why we reuse original floorboards',
)
expect(textOf(changes[1])).toBe('Body Body edited (+312 characters)')
expect(changes[1]!.querySelector('[data-testid="activity-before"]')).toBeNull()
})
it.each([
[1, 'Body edited (+1 character)'],
[-48, 'Body edited (-48 characters)'],
])('words a body delta of %i', async (delta, text) => {
await openWith(makeItem({ id: 1, changes: [{ field: 'body', kind: 'body', delta }] }))
expect(textOf(details()!.querySelector('[data-testid="activity-body-change"]'))).toBe(text)
})
it('labels the fields, reads online as Draft and Published, and keeps unknown codes', async () => {
const changes: ActivityChange[] = [
{ field: 'online', kind: 'text', before: 'false', after: 'true' },
{ field: 'filename', kind: 'text', before: 'a.png', after: 'b.png' },
{ field: 'display_name', kind: 'text', before: 'Sam', after: 'Samuel' },
{ field: 'tagline', kind: 'text', before: 'x', after: 'y' },
]
await openWith(makeItem({ id: 1, changes }))
const rowsText = Array.from(
details()!.querySelectorAll('[data-testid="activity-change"]'),
).map(textOf)
expect(rowsText).toEqual([
'Publish stateBeforeDraftAfterPublished',
'File nameBeforea.pngAfterb.png',
'Display nameBeforeSamAfterSamuel',
'taglineBeforexAftery',
])
})
it('clamps long values and toggles Show more on that row only', async () => {
const scrollHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollHeight')
const clientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight')
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
get() {
return (this.textContent ?? '').length > 100 ? 80 : 20
},
})
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
get() {
return 40
},
})
try {
await openWith(
makeItem({
id: 1,
changes: [
{
field: 'title',
kind: 'text',
before: LONG_VALUE,
after: `${LONG_VALUE}more`,
},
{ field: 'filename', kind: 'text', before: 'a.png', after: 'b.png' },
],
}),
)
await vi.waitFor(() => expect(findButton('Show more')).toBeTruthy())
const changes = Array.from(
details()!.querySelectorAll('[data-testid="activity-change"]'),
)
expect(changes[0]!.querySelector('.line-clamp-2')).toBeTruthy()
expect(changes[1]!.querySelector('button')).toBeNull()
expect(
Array.from(document.body.querySelectorAll('button')).filter(
(button) => textOf(button) === 'Show more',
),
).toHaveLength(1)
await click(findButton('Show more'))
expect(changes[0]!.querySelector('.line-clamp-2')).toBeNull()
expect(findButton('Show less')).toBeTruthy()
await click(findButton('Show less'))
expect(changes[0]!.querySelector('.line-clamp-2')).toBeTruthy()
} finally {
if (scrollHeight)
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', scrollHeight)
if (clientHeight)
Object.defineProperty(HTMLElement.prototype, 'clientHeight', clientHeight)
}
})
it('shows the failure reason and wraps a very long target title', async () => {
await openWith(
makeItem({
id: 1,
outcome: 'failed',
failure_reason: 'The article has no cover image. Add one, then publish again.',
target: target('article', '6', 'T'.repeat(150)),
}),
)
const sentence = details()!.querySelector('[data-testid="activity-details-sentence"]')!
expect(sentence.className).toContain('wrap-anywhere')
expect(textOf(sentence.querySelector('.font-semibold'))).toBe('T'.repeat(150))
expect(textOf(details())).toContain('Failed')
const alert = details()!.querySelector('[role="alert"]')!
expect(textOf(alert)).toContain('Why it failed')
expect(textOf(alert)).toContain(
'The article has no cover image. Add one, then publish again.',
)
expect(
details()!.querySelector('[data-testid="activity-target-section"] .wrap-anywhere'),
).toBeTruthy()
})
it('shows no Why it failed alert for a failed event without a reason', async () => {
await openWith(makeItem({ id: 1, outcome: 'failed', failure_reason: null }))
expect(details()!.querySelector('[role="alert"]')).toBeNull()
})
it('shows a deleted target struck through with its explanation and no Changes', async () => {
await openWith(
makeItem({
id: 1,
action: 'media.delete',
category: 'media',
target: target('image', '9', 'old-banner.png', false),
}),
)
const section = details()!.querySelector('[data-testid="activity-target-section"]')!
expect(section.querySelector('.line-through')?.textContent).toBe('old-banner.png')
expect(textOf(section)).toContain('Deleted')
expect(textOf(section)).toContain('Image · 9')
expect(textOf(section)).toContain('This item was deleted. Its name is kept for reference.')
expect(textOf(details())).not.toContain('Changes')
})
it('shows a living target as a link with its type and id', async () => {
await openWith(makeItem({ id: 1 }))
const section = details()!.querySelector('[data-testid="activity-target-section"]')!
expect(section.querySelector('a')?.getAttribute('href')).toBe('/article/8/edit')
expect(textOf(section)).toContain('Article · 8')
await expectIcon(section, 'arrow-up-right')
})
it('omits the Target section when there is no target', async () => {
await openWith(makeItem({ id: 1, action: 'auth.login', target: null }))
expect(details()!.querySelector('[data-testid="activity-target-section"]')).toBeNull()
})
it('shows a system event without a human actor', async () => {
await openWith(
makeItem({
id: 1,
action: 'system.media_purge',
category: 'system',
actor: system,
target: null,
source: 'system',
ip: null,
user_agent: null,
params: { merged: 3, articles_updated: 5 },
}),
)
const text = textOf(details())
expect(textOf(details()!.querySelector('[data-testid="activity-actor"]'))).toBe(
'SystemSystem',
)
await expectIcon(details()!.querySelector('[data-testid="activity-actor"]'), 'cog')
expect(text).toContain('System task')
expect(text.match(/Not applicable/g)).toHaveLength(2)
})
it('shows a failed login as not signed in with the reason, IP and browser', async () => {
await openWith(
makeItem({
id: 1,
action: 'auth.login_failed',
category: 'auth',
actor: anonymous('alex@example.com'),
target: null,
outcome: 'failed',
security: true,
failure_reason: 'Wrong username or password.',
ip: '198.51.100.77',
user_agent: 'Chrome 129 on Windows',
}),
)
expect(textOf(details()!.querySelector('[data-testid="activity-details-sentence"]'))).toBe(
'Failed login attempt for alex@example.com',
)
const text = textOf(details())
expect(text).toContain('Security')
expect(text).toContain('Failed')
expect(textOf(details()!.querySelector('[data-testid="activity-actor"]'))).toBe(
'Not signed in',
)
expect(textOf(details()!.querySelector('[role="alert"]'))).toContain(
'Wrong username or password.',
)
expect(text).toContain('198.51.100.77')
expect(text).toContain('Chrome 129 on Windows')
})
it('shows the time in your time zone and in UTC', async () => {
await openWith(makeItem({ id: 1, created_at: '2026-10-06T10:42:18Z' }))
const when = Array.from(details()!.querySelectorAll('dt')).find(
(term) => textOf(term) === 'When',
)!.nextElementSibling!
const lines = Array.from(when.querySelectorAll('span')).map(textOf)
expect(lines[0]).toMatch(/^6 Oct 2026, \d{2}:\d{2}:\d{2} \(your time\)$/)
expect(lines[1]).toBe('6 Oct 2026, 10:42:18 UTC')
})
it.each([
['web', 'Web app'],
['api_key', 'API key'],
['system', 'System task'],
] as const)('reads source %s as %s', async (source, label) => {
await openWith(makeItem({ id: 1, source }))
const term = Array.from(details()!.querySelectorAll('dt')).find(
(candidate) => textOf(candidate) === 'Source',
)!
expect(textOf(term.nextElementSibling)).toBe(label)
})
})
describe('Details: Previous and Next across pages', () => {
function numbered(page: number) {
return Array.from({ length: 25 }, (_, index) =>
makeItem({ id: (page - 1) * 25 + index + 1, created_at: '2026-10-03T12:30:00Z' }),
)
}
function pagedList(size = 25) {
return (query: URLSearchParams) => {
const page = Number(query.get('page'))
return pageOf(numbered(page), { page, size })
}
}
const footer = () => textOf(panel())
const previous = () => findButton('Previous event')!
const next = () => findButton('Next event')!
it('moves through the list, fetches the neighbour pages and stops at the ends', async () => {
await mountActivity({ list: pagedList() })
await openRow(0)
expect(previous().disabled).toBe(true)
expect(footer()).toContain('Event 1 of 1,284')
for (let step = 0; step < 24; step++) await click(next())
expect(footer()).toContain('Event 25 of 1,284')
expect(callsTo(LIST_PATH)).toHaveLength(1)
await click(next())
expect(queryObject(callsTo(LIST_PATH).at(-1))).toEqual({
range: '7d',
page: '2',
size: '25',
})
expect(footer()).toContain('Event 26 of 1,284')
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
'Showing 26–50 of 1,284',
)
expect(
textOf(details()!.querySelector('[data-testid="activity-details-sentence"]')),
).toContain('Spring release')
await click(previous())
expect(queryObject(callsTo(LIST_PATH).at(-1))).toMatchObject({ page: '1' })
expect(footer()).toContain('Event 25 of 1,284')
expect(
rows()
.map((row) => row.getAttribute('data-activity-id'))
.at(-1),
).toBe('25')
})
it('disables Next on the very last event', async () => {
await mountActivity({
list: () => pageOf([makeItem({ id: 1 }), makeItem({ id: 2 })], { total: 2 }),
})
await openRow(1)
expect(next().disabled).toBe(true)
expect(footer()).toContain('Event 2 of 2')
})
it('keeps the panel on the current event when the neighbour page fails', async () => {
await mountActivity({
list: (query) => {
if (query.get('page') === '2') throw new ApiError(500)
return pageOf(numbered(1))
},
})
await openRow(24)
const ids = rows().map((row) => row.getAttribute('data-activity-id'))
await click(next())
expect(footer()).toContain('Event 25 of 1,284')
expect(next().disabled).toBe(false)
expect(rows().map((row) => row.getAttribute('data-activity-id'))).toEqual(ids)
expect(document.body.querySelector('[role="alert"]')).toBeNull()
})
it('numbers events from the page size', async () => {
await mountActivity({ list: pagedList(50) })
harness.wrapper!.findComponent(ActivityPager).vm.$emit('update:size', 50)
await flushPromises()
harness.wrapper!.findComponent(ActivityPager).vm.$emit('update:page', 2)
await flushPromises()
await openRow(0)
expect(footer()).toContain('Event 51 of 1,284')
})
})
describe('Details below md: a bottom sheet', () => {
it('shows the position in the header and full-width buttons without footer text', async () => {
await mountActivity({ desktop: false })
await openRow(1)
const dialog = panel()!
expect(textOf(dialog)).toContain('Activity details')
expect(textOf(dialog)).toContain('2 of 1,284')
expect(textOf(dialog)).not.toContain('Event 2 of 1,284')
expect(findButton('Previous event')!.className).toContain('max-md:flex-1')
expect(findButton('Next event')!.className).toContain('max-md:flex-1')
expect(dialog.getAttribute('data-side') ?? dialog.className).toContain('bottom')
expect(dialog.className).toContain('h-[88%]')
})
})
describe('Details: counts', () => {
it('counts positions against the filtered total', async () => {
await mountActivity({ list: () => pageOf(activityItems, { total: 41 }) })
await openRow(0)
expect(textOf(panel())).toContain('Event 1 of 41')
})
})
@@ -0,0 +1,509 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { flushPromises } from '@vue/test-utils'
import { CalendarDate } from '@internationalized/date'
import UCalendar from '@nuxt/ui/components/Calendar.vue'
import USelectMenu from '@nuxt/ui/components/SelectMenu.vue'
import router from '@/router'
import { useApi } from '@/composables/useApi'
import { initializeLocale } from '@/i18n'
import ActivityCategorySelect from '@/components/ActivityCategorySelect.vue'
import ActivityPager from '@/components/ActivityPager.vue'
import ActivityPersonSelect from '@/components/ActivityPersonSelect.vue'
import {
KEYS_PATH,
LIST_PATH,
SUMMARY_PATH,
USERS_PATH,
callsTo,
chipTexts,
click,
clickButton,
findButton,
harness,
lastListQuery,
mountActivity,
queryObject,
rows,
stripCards,
textOf,
unmountActivity,
} from './activityHarness'
import { activityItems, makeItem, pageOf, summaryDefault } from './activityFixtures'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: vi.fn() }) }
})
const DEFAULT_QUERY = { range: '7d', page: '1', size: '25' }
beforeEach(() => {
localStorage.clear()
initializeLocale()
})
afterEach(async () => {
vi.useRealTimers()
unmountActivity()
vi.mocked(useApi).mockReset()
await router.push('/login')
})
function searchInput() {
return document.body.querySelector<HTMLInputElement>('input[type="text"]')!
}
async function typeSearch(value: string) {
const input = searchInput()
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
return input
}
async function goToPage(page: number) {
harness.wrapper!.findComponent(ActivityPager).vm.$emit('update:page', page)
await flushPromises()
}
function group(label: string) {
return document.body.querySelector(`[role="group"][aria-label="${label}"]`)!
}
async function clickIn(label: string, text: string) {
const button = Array.from(group(label).querySelectorAll('button')).find(
(candidate) => textOf(candidate) === text,
)
await click(button)
}
describe('Filter bar: each control sends its query parameter and resets the page', () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
})
async function onPage3() {
await mountActivity({
list: (query) => pageOf(activityItems, { page: Number(query.get('page')) }),
})
await goToPage(3)
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, page: '3' })
}
it('types "spring" and waits 300 ms', async () => {
await onPage3()
const before = callsTo(LIST_PATH).length
await typeSearch('spring')
vi.advanceTimersByTime(299)
await flushPromises()
expect(callsTo(LIST_PATH)).toHaveLength(before)
vi.advanceTimersByTime(1)
await flushPromises()
expect(callsTo(LIST_PATH)).toHaveLength(before + 1)
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, q: 'spring' })
})
it('types "spring" and presses Enter at once', async () => {
await onPage3()
const before = callsTo(LIST_PATH).length
const input = await typeSearch('spring')
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
await flushPromises()
expect(callsTo(LIST_PATH)).toHaveLength(before + 1)
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, q: 'spring' })
vi.advanceTimersByTime(500)
await flushPromises()
expect(callsTo(LIST_PATH)).toHaveLength(before + 1)
})
it.each([
['Marta Alves (user 3)', 'user:3', 'Marta Alves', { actor: 'user:3' }],
['Website sync (API key 7)', 'key:7', 'Website sync', { actor: 'key:7' }],
['System', 'system', 'System', { actor: 'system' }],
])('chooses Person %s', async (_, value, label, expected) => {
await onPage3()
harness.wrapper!.findComponent(ActivityPersonSelect).vm.$emit('select', value, label)
await flushPromises()
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, ...expected })
expect(chipTexts()).toEqual([`Person: ${label}`])
})
it('chooses Category "Users and invites"', async () => {
await onPage3()
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'users')
await flushPromises()
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, category: 'users' })
})
it('chooses Outcome "Succeeded", then "All"', async () => {
await onPage3()
await clickIn('Outcome', 'Succeeded')
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, outcome: 'success' })
expect(chipTexts()).toEqual(['Outcome: Succeeded'])
await clickIn('Outcome', 'All')
expect(queryObject(lastListQuery())).toEqual(DEFAULT_QUERY)
})
it.each([
['Today', { range: 'today', page: '1', size: '25' }],
['30 days', { range: '30d', page: '1', size: '25' }],
])('chooses "%s"', async (label, expected) => {
await onPage3()
await clickIn('Dates', label)
expect(queryObject(lastListQuery())).toEqual(expected)
expect(queryObject(callsTo(SUMMARY_PATH).at(-1))).toEqual({ range: expected.range })
})
})
describe('Filter bar: custom dates', () => {
it('applies on the second date and shows the range chip', async () => {
await mountActivity()
await clickIn('Dates', 'Custom')
await vi.waitFor(() =>
expect(harness.wrapper!.findComponent(UCalendar).exists()).toBe(true),
)
const calendar = harness.wrapper!.findComponent(UCalendar)
calendar.vm.$emit('update:modelValue', {
start: new CalendarDate(2026, 9, 1),
end: undefined,
})
await flushPromises()
expect(callsTo(LIST_PATH)).toHaveLength(1)
expect(callsTo(SUMMARY_PATH)).toHaveLength(1)
calendar.vm.$emit('update:modelValue', {
start: new CalendarDate(2026, 9, 1),
end: new CalendarDate(2026, 9, 30),
})
await flushPromises()
const expected = { range: 'custom', from: '2026-09-01', to: '2026-09-30' }
expect(queryObject(lastListQuery())).toEqual({ ...expected, page: '1', size: '25' })
expect(queryObject(callsTo(SUMMARY_PATH).at(-1))).toEqual(expected)
expect(chipTexts()).toEqual(['Dates: 1 – 30 Sep 2026'])
})
it('words a range across months', async () => {
await mountActivity()
await clickIn('Dates', 'Custom')
await vi.waitFor(() =>
expect(harness.wrapper!.findComponent(UCalendar).exists()).toBe(true),
)
harness.wrapper!.findComponent(UCalendar).vm.$emit('update:modelValue', {
start: new CalendarDate(2026, 9, 28),
end: new CalendarDate(2026, 10, 3),
})
await flushPromises()
expect(chipTexts()).toEqual(['Dates: 28 Sep – 3 Oct 2026'])
})
})
describe('Chips', () => {
it('removes one filter at a time and clears all', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await mountActivity()
await typeSearch('spring')
vi.advanceTimersByTime(300)
await flushPromises()
await clickIn('Outcome', 'Failed')
await clickIn('Dates', '30 days')
expect(chipTexts()).toEqual(['Search: spring', 'Outcome: Failed', 'Dates: 30 days'])
expect(callsTo(SUMMARY_PATH)).toHaveLength(2)
await click(findButton('Remove filter Outcome: Failed'))
expect(queryObject(lastListQuery())).toEqual({
range: '30d',
page: '1',
size: '25',
q: 'spring',
})
expect(chipTexts()).toEqual(['Search: spring', 'Dates: 30 days'])
expect(callsTo(SUMMARY_PATH)).toHaveLength(2)
await clickButton('Clear all')
expect(queryObject(lastListQuery())).toEqual(DEFAULT_QUERY)
expect(queryObject(callsTo(SUMMARY_PATH).at(-1))).toEqual({ range: '7d' })
expect(chipTexts()).toEqual([])
expect(findButton('Clear all')).toBeUndefined()
expect(searchInput().value).toBe('')
})
})
describe('Strip cards apply their filter', () => {
it('clears every filter from the Total card', async () => {
await mountActivity()
await clickIn('Outcome', 'Failed')
expect(chipTexts()).toEqual(['Outcome: Failed'])
await click(stripCards()[0])
expect(queryObject(lastListQuery())).toEqual(DEFAULT_QUERY)
expect(chipTexts()).toEqual([])
stripCards().forEach((card) => expect(card.getAttribute('aria-pressed')).not.toBe('true'))
})
it('does nothing from the Active people card', async () => {
await mountActivity()
const before = harness.calls.length
await click(stripCards()[1])
expect(harness.calls).toHaveLength(before)
expect(stripCards()[1]!.hasAttribute('aria-pressed')).toBe(false)
})
it('applies outcome=failed from the Failed card and marks it pressed', async () => {
await mountActivity()
await click(stripCards()[2])
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, outcome: 'failed' })
expect(chipTexts()).toEqual(['Outcome: Failed'])
expect(findButton('Clear all')?.disabled).toBe(false)
expect(stripCards()[2]!.getAttribute('aria-pressed')).toBe('true')
expect(stripCards()[2]!.className).toContain('border-primary')
})
it('applies security=true from the Security card and marks it pressed', async () => {
await mountActivity()
await click(stripCards()[3])
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, security: 'true' })
expect(chipTexts()).toEqual(['Security events'])
expect(stripCards()[3]!.getAttribute('aria-pressed')).toBe('true')
})
it('stacks the Failed card on other filters without asking for the summary again', async () => {
await mountActivity({
list: (query) =>
pageOf([makeItem({ id: 3 })], { total: query.get('outcome') ? 6 : 1284 }),
})
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'auth')
await flushPromises()
await clickIn('Dates', '30 days')
await click(stripCards()[2])
expect(chipTexts()).toEqual([
'Category: Authentication',
'Outcome: Failed',
'Dates: 30 days',
])
expect(callsTo(SUMMARY_PATH)).toHaveLength(2)
expect(queryObject(callsTo(SUMMARY_PATH).at(-1))).toEqual({ range: '30d' })
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
'Showing 1–6 of 6',
)
})
})
describe('The Person menu', () => {
it('loads its options once, on first open, and lists users, keys then System', async () => {
await mountActivity({
users: () => [
{
kind: 'user',
id: 3,
username: 'marta',
display_name: 'Marta Alves',
role: 'owner',
},
{ kind: 'user', id: 4, username: 'jonas', display_name: '', role: 'admin' },
{ kind: 'invite', id: 9, email: 'x@example.com', status: 'pending' },
],
keys: () => [{ id: 7, name: 'Website sync' }],
})
expect(callsTo(USERS_PATH)).toHaveLength(0)
expect(callsTo(KEYS_PATH)).toHaveLength(0)
const person = harness.wrapper!.findComponent(ActivityPersonSelect)
person.findComponent(USelectMenu).vm.$emit('update:open', true)
await flushPromises()
expect(callsTo(USERS_PATH)).toHaveLength(1)
expect(callsTo(KEYS_PATH)).toHaveLength(1)
const groups = person.findComponent(USelectMenu).props('items') as Record<
string,
string
>[][]
expect(groups.map((items) => items.map((item) => item.label))).toEqual([
['Users', 'Marta Alves', 'jonas'],
['API keys', 'Website sync'],
['System'],
])
expect(
groups
.flat()
.map((item) => item.value)
.filter(Boolean),
).toEqual(['user:3', 'user:4', 'key:7', 'system'])
person.findComponent(USelectMenu).vm.$emit('update:open', true)
await flushPromises()
expect(callsTo(USERS_PATH)).toHaveLength(1)
expect(callsTo(KEYS_PATH)).toHaveLength(1)
})
})
describe('Pagination', () => {
it('moves to page 2, changes the page size and reaches the last page', async () => {
await mountActivity({
list: (query) =>
pageOf(activityItems, {
page: Number(query.get('page')),
size: Number(query.get('size')),
}),
})
const pager = () => textOf(document.body.querySelector('[data-testid="activity-pager"]'))
await goToPage(2)
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, page: '2' })
expect(pager()).toContain('Showing 26–50 of 1,284')
const activePage = document.body.querySelector(
'[data-testid="activity-pager"] [aria-current="page"]',
)
expect(textOf(activePage)).toBe('2')
harness.wrapper!.findComponent(ActivityPager).vm.$emit('update:size', 50)
await flushPromises()
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, page: '1', size: '50' })
expect(pager()).toContain('Showing 1–50 of 1,284')
harness.wrapper!.findComponent(ActivityPager).vm.$emit('update:size', 25)
await flushPromises()
await goToPage(52)
expect(pager()).toContain('Showing 1,276–1,284 of 1,284')
expect(harness.wrapper!.findComponent(ActivityPager).props('page')).toBe(52)
expect(rows()).toHaveLength(activityItems.length)
})
})
describe('Mobile: the list is cards and the controls collapse into a sheet', () => {
function mobileList() {
return mountActivity({
desktop: false,
list: () => pageOf(activityItems, { total: 6 }),
})
}
async function applyAuthFailed() {
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'auth')
await flushPromises()
}
it('shows search and Filters with a badge, cards, and the short pager', async () => {
await mobileList()
const sheet = () => document.body.querySelector('[data-testid="activity-filters-sheet"]')
expect(document.body.querySelector('[role="group"][aria-label="Outcome"]')).toBeNull()
expect(document.body.querySelector('[data-testid="activity-filter-count"]')).toBeNull()
expect(textOf(findButton('Filters'))).toBe('Filters')
expect(sheet()).toBeNull()
await click(findButton('Filters'))
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'auth')
await flushPromises()
await clickIn('Outcome', 'Failed')
expect(textOf(document.body.querySelector('[data-testid="activity-filter-count"]'))).toBe(
'2',
)
expect(rows().every((row) => row.getAttribute('data-testid') === 'activity-card')).toBe(
true,
)
expect(document.body.querySelector('[role="table"]')).toBeNull()
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
'Page 1 of 1',
)
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
'1–6 of 6',
)
})
it('draws the sheet with its footer and closes it from "Show 6 results"', async () => {
await mobileList()
await click(findButton('Filters'))
await applyAuthFailed()
const sheet = document.body.querySelector('[data-testid="activity-filters-sheet"]')!
expect(document.body.querySelector('[role="dialog"]')?.textContent).toContain('Filters')
expect(textOf(sheet)).toContain('Person')
expect(textOf(sheet)).toContain('Category')
expect(textOf(sheet)).toContain('Outcome')
expect(textOf(sheet)).toContain('Dates')
expect(findButton('Clear all')?.disabled).toBe(false)
expect(sheet.querySelectorAll('input[type="date"]')).toHaveLength(0)
await clickIn('Dates', 'Custom')
expect(sheet.querySelectorAll('input[type="date"]')).toHaveLength(2)
await clickButton('Show 6 results')
await vi.waitFor(() =>
expect(
document.body.querySelector('[data-testid="activity-filters-sheet"]'),
).toBeNull(),
)
expect(chipTexts()).toEqual(['Category: Authentication'])
})
it('applies the two custom dates from the sheet', async () => {
await mobileList()
await click(findButton('Filters'))
await clickIn('Dates', 'Custom')
const [from, to] = Array.from(
document.body.querySelectorAll<HTMLInputElement>('input[type="date"]'),
)
from!.value = '2026-09-01'
from!.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
expect(callsTo(LIST_PATH)).toHaveLength(1)
to!.value = '2026-09-30'
to!.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
expect(queryObject(lastListQuery())).toEqual({
range: 'custom',
from: '2026-09-01',
to: '2026-09-30',
page: '1',
size: '25',
})
})
it('words the footer for a single result and disables Clear all at 0 filters', async () => {
await mountActivity({
desktop: false,
list: () => pageOf([activityItems[0]!], { total: 1 }),
})
await click(findButton('Filters'))
expect(textOf(findButton('Show 1 result'))).toBe('Show 1 result')
expect(findButton('Clear all')?.disabled).toBe(true)
})
})
describe('Summary follows only the date range', () => {
it('does not request the summary again for the other filters', async () => {
await mountActivity({ summary: () => summaryDefault })
await clickIn('Outcome', 'Failed')
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'media')
await flushPromises()
expect(callsTo(SUMMARY_PATH)).toHaveLength(1)
})
})
@@ -0,0 +1,238 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import { initializeLocale, setLocale } from '@/i18n'
import {
EXPORT_PATH,
LIST_PATH,
SUMMARY_PATH,
callsTo,
click,
days,
findButton,
harness,
mountActivity,
queryObject,
rows,
stripCards,
textOf,
unmountActivity,
} from './activityHarness'
import ActivityCategorySelect from '@/components/ActivityCategorySelect.vue'
import { activityItems, makeItem, pageOf, summaryDefault } from './activityFixtures'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: toastAdd }) }
})
const clicked: HTMLAnchorElement[] = []
beforeEach(() => {
toastAdd = vi.fn()
clicked.length = 0
localStorage.clear()
initializeLocale()
URL.createObjectURL = vi.fn(() => 'blob:activity')
URL.revokeObjectURL = vi.fn()
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
this: HTMLAnchorElement,
) {
clicked.push(this)
})
})
afterEach(async () => {
vi.restoreAllMocks()
unmountActivity()
vi.mocked(useApi).mockReset()
localStorage.clear()
initializeLocale()
await router.push('/login')
})
function toastCall(index = 0) {
const options = toastAdd.mock.calls[index]![0] as {
color: string
icon: string
title: string
}
return { color: options.color, icon: options.icon, title: options.title }
}
describe('Export', () => {
it('prepares the file with the list filters, then downloads it and toasts', async () => {
let release: (blob: Blob) => void = () => undefined
const pending = new Promise<Blob>((resolve) => (release = resolve))
await mountActivity({ exportFile: () => pending })
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'auth')
await flushPromises()
await click(
Array.from(
document.body.querySelectorAll<HTMLButtonElement>(
'[role="group"][aria-label="Dates"] button',
),
).find((button) => textOf(button) === '30 days'),
)
await click(findButton('Export CSV'))
expect(callsTo(EXPORT_PATH)).toHaveLength(1)
expect(queryObject(callsTo(EXPORT_PATH)[0])).toEqual({ category: 'auth', range: '30d' })
const busy = findButton('Preparing file…')!
expect(busy.disabled).toBe(true)
expect(clicked).toHaveLength(0)
release(new Blob(['when,actor'], { type: 'text/csv' }))
await flushPromises()
expect(clicked).toHaveLength(1)
expect(clicked[0]!.download).toBe('activity.csv')
expect(URL.createObjectURL).toHaveBeenCalledTimes(1)
expect(toastCall()).toEqual({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Export started. Your file will download shortly.',
})
expect(findButton('Export CSV')?.disabled).toBe(false)
})
it.each([
['a 500 response', () => new ApiError(500)],
['a 403 response', () => new ApiError(403)],
['a network failure', () => new NetworkError()],
])('raises an error toast after %s and re-enables the button', async (_, failure) => {
await mountActivity({
exportFile: () => {
throw failure()
},
})
await click(findButton('Export CSV'))
expect(toastCall()).toEqual({
color: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't export activity. Try again.",
})
expect(clicked).toHaveLength(0)
expect(findButton('Export CSV')?.disabled).toBe(false)
})
})
describe('My activity: a role that is not owner or admin sees its own events', () => {
it.each([['editor'], [undefined]])('role %s', async (role) => {
await mountActivity({
role,
list: () => pageOf(activityItems, { total: 41 }),
})
expect(textOf(document.body.querySelector('h1'))).toBe('My activity')
expect(textOf(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toBe(
'My activity',
)
expect(textOf(document.body)).toContain("Everything you've done on this site.")
expect(stripCards()).toHaveLength(0)
expect(textOf(document.body)).not.toContain('All people')
expect(findButton('Export CSV')).toBeUndefined()
const headers = Array.from(document.body.querySelectorAll('[role="columnheader"]')).map(
textOf,
)
expect(headers).toEqual(['When', 'Action', 'Target', 'Outcome'])
expect(document.body.querySelector('[data-testid="activity-actor"]')).toBeNull()
expect(callsTo(SUMMARY_PATH)).toHaveLength(0)
expect(callsTo(EXPORT_PATH)).toHaveLength(0)
expect(queryObject(callsTo(LIST_PATH)[0])).toEqual({ range: '7d', page: '1', size: '25' })
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
'Showing 1–25 of 41',
)
})
it('shows no actor line on the cards below md', async () => {
await mountActivity({ role: 'editor', desktop: false })
expect(rows()).toHaveLength(activityItems.length)
expect(document.body.querySelector('[data-testid="activity-actor"]')).toBeNull()
})
it('shows its own empty copy', async () => {
await mountActivity({ role: 'editor', list: () => pageOf([], { total: 0 }) })
expect(textOf(document.body)).toContain('No activity yet')
expect(textOf(document.body)).toContain(
'Your actions will appear here once you start working on the site.',
)
})
it('shows the same details panel with the user position', async () => {
await mountActivity({ role: 'editor', list: () => pageOf(activityItems, { total: 41 }) })
rows()[0]!.click()
await flushPromises()
await vi.waitFor(() => expect(document.body.querySelector('[role="dialog"]')).toBeTruthy())
expect(textOf(document.body.querySelector('[role="dialog"]'))).toContain('Event 1 of 41')
expect((findButton('Previous event') as HTMLButtonElement).disabled).toBe(true)
expect(textOf(document.body.querySelector('[data-testid="activity-actor"]'))).toBe(
'MAMarta Alves',
)
})
})
describe('The French interface', () => {
it('composes the chrome, the strip, the sentences and the day separators in French', async () => {
setLocale('fr')
await mountActivity({
summary: () => summaryDefault,
list: () =>
pageOf([
makeItem({ id: 1, created_at: '2026-10-03T12:30:00Z' }),
makeItem({
id: 2,
created_at: '2026-10-03T12:20:00Z',
outcome: 'failed',
target: { type: 'article', id: '8', name: 'Spring release', exists: true },
}),
]),
})
expect(textOf(document.body.querySelector('h1'))).toBe('Activité')
expect(textOf(findButton('Exporter en CSV'))).toBe('Exporter en CSV')
const strip = stripCards().map(textOf)
expect(strip[0]).toContain('Actions au total')
expect(strip[1]).toContain('Personnes actives')
expect(strip[1]).toContain('7 personnes')
expect(strip[2]).toContain('Actions en échec')
expect(strip[3]).toContain('Événements de sécurité')
expect(strip[3]).toContain('11 signalés')
expect(days()).toEqual(['3 oct. 2026'])
const sentences = Array.from(
document.body.querySelectorAll('[data-testid="activity-sentence"]'),
).map(textOf)
expect(sentences).toEqual([
'Article publié : Spring release',
"Échec de la publication de l'article Spring release",
])
const search = document.body.querySelector<HTMLInputElement>('input[type="text"]')!
expect(search.placeholder).toBe("Rechercher dans l'activité")
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
'Lignes par page',
)
expect(textOf(rows()[0]!)).toContain('Réussie')
expect(textOf(rows()[1]!)).toContain('Échec')
})
it('shows the French My activity title', async () => {
setLocale('fr')
await mountActivity({ role: 'editor' })
expect(textOf(document.body.querySelector('h1'))).toBe('Mon activité')
})
})
@@ -0,0 +1,666 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import { initializeLocale } from '@/i18n'
import type { ActivityItem } from '@/lib/activity'
import {
LIST_PATH,
SUMMARY_PATH,
callsTo,
chipTexts,
clickButton,
days,
findButton,
expectIcon,
mountActivity,
queryObject,
rows,
stripCards,
textOf,
unmountActivity,
} from './activityHarness'
import {
activityItems,
anonymous,
apiKey,
makeItem,
pageOf,
summaryDefault,
summaryNone,
summaryZero,
system,
target,
user,
} from './activityFixtures'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: toastAdd }) }
})
beforeEach(() => {
toastAdd = vi.fn()
localStorage.clear()
initializeLocale()
})
afterEach(async () => {
vi.useRealTimers()
unmountActivity()
vi.mocked(useApi).mockReset()
await router.push('/login')
})
function pageText() {
return textOf(document.body)
}
function stripTexts() {
return stripCards().map(textOf)
}
describe('Navigation', () => {
it('lists Activity between Users and Integration with the history icon and the current page', async () => {
await mountActivity()
const links = Array.from(
document.body.querySelectorAll<HTMLAnchorElement>('nav[aria-label="Main"] a'),
)
expect(links.map((link) => link.getAttribute('href'))).toEqual([
'/home',
'/article',
'/media',
'/users',
'/activity',
'/integration',
'/settings',
])
const activity = links[4]!
expect(activity.getAttribute('aria-current')).toBe('page')
await expectIcon(activity, 'history')
expect(textOf(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toBe('Activity')
expect(router.currentRoute.value.meta).toEqual({ auth: 'verified', title: 'Activity' })
})
})
describe('Default: an owner sees the strip, the filter bar and a day-grouped table', () => {
it('draws the header, the strip, the filter bar, the rows and the pager', async () => {
await mountActivity()
expect(textOf(document.body.querySelector('h1'))).toBe('Activity')
expect(pageText()).toContain('Who did what, and when, across your site.')
expect(findButton('Export CSV')?.disabled).toBe(false)
expect(stripTexts()).toEqual([
expect.stringContaining('Total actions'),
expect.stringContaining('Active people'),
expect.stringContaining('Failed actions'),
expect.stringContaining('Security events'),
])
expect(stripTexts()[0]).toContain('1,284')
expect(stripTexts()[0]).toContain('12% vs previous 7 days')
expect(stripTexts()[1]).toContain('9')
expect(stripTexts()[1]).toContain('7 people, 2 API keys')
expect(stripTexts()[2]).toContain('0.5% of all actions')
expect(stripTexts()[3]).toContain('11 flagged')
await expectIcon(stripCards()[0], 'trending-up')
await expectIcon(stripCards()[2], 'circle-x')
await expectIcon(stripCards()[3], 'shield-alert')
const search = document.body.querySelector<HTMLInputElement>('input[type="text"]')!
expect(search.placeholder).toBe('Search activity')
expect(pageText()).toContain('All people')
expect(pageText()).toContain('All categories')
const outcome = document.body.querySelector('[role="group"][aria-label="Outcome"]')!
expect(outcome.querySelector('[aria-pressed="true"]')?.textContent?.trim()).toBe('All')
const dates = document.body.querySelector('[role="group"][aria-label="Dates"]')!
expect(dates.querySelector('[aria-pressed="true"]')?.textContent?.trim()).toBe('7 days')
expect(chipTexts()).toEqual([])
expect(rows()).toHaveLength(activityItems.length)
expect(days()).toEqual(['3 Oct 2026', '2 Oct 2026'])
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
'Showing 1–25 of 1,284',
)
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
'Rows per page',
)
})
it('requests the list and the summary once each with the default query', async () => {
await mountActivity()
expect(callsTo(LIST_PATH)).toHaveLength(1)
expect(queryObject(callsTo(LIST_PATH)[0])).toEqual({ range: '7d', page: '1', size: '25' })
expect(callsTo(SUMMARY_PATH)).toHaveLength(1)
expect(queryObject(callsTo(SUMMARY_PATH)[0])).toEqual({ range: '7d' })
})
it('gives every When cell the full local time with seconds as its title', async () => {
await mountActivity()
const cells = Array.from(
document.body.querySelectorAll<HTMLElement>('[data-testid="activity-when"]'),
)
expect(cells).toHaveLength(activityItems.length)
for (const cell of cells) {
expect(cell.textContent?.trim()).toMatch(/^\d{2}:\d{2}$/)
expect(cell.title).toMatch(/^\d{1,2} \w{3} 2026, \d{2}:\d{2}:\d{2}$/)
}
})
})
describe('Rows: the Actor cell and the sentence follow the event', () => {
const cases: {
name: string
item: ActivityItem
actor: string
sentence: string
badge: string
shield: boolean
}[] = [
{
name: 'user, success',
item: makeItem({ id: 1 }),
actor: 'Marta Alves',
sentence: 'Published the article Spring release',
badge: 'Succeeded',
shield: false,
},
{
name: 'api key',
item: makeItem({
id: 2,
action: 'media.upload',
category: 'media',
actor: apiKey(7, 'Website sync'),
target: target('image', '4', 'north-wall.jpg'),
}),
actor: 'Website sync API key',
sentence: 'Uploaded the image north-wall.jpg',
badge: 'Succeeded',
shield: false,
},
{
name: 'system',
item: makeItem({
id: 3,
action: 'system.media_purge',
category: 'system',
actor: system,
target: null,
params: { merged: 3, articles_updated: 5 },
}),
actor: 'System System',
sentence: 'Ran the scheduled duplicate purge: 3 merged, 5 articles updated',
badge: 'Succeeded',
shield: false,
},
{
name: 'anonymous failed login',
item: makeItem({
id: 4,
action: 'auth.login_failed',
category: 'auth',
actor: anonymous('alex@example.com'),
target: null,
outcome: 'failed',
security: true,
}),
actor: 'Not signed in',
sentence: 'Failed login attempt for alex@example.com',
badge: 'Failed',
shield: true,
},
{
name: 'failed publish',
item: makeItem({ id: 5, actor: user(4, 'Inès Duarte'), outcome: 'failed' }),
actor: 'Inès Duarte',
sentence: 'Failed to publish the article Spring release',
badge: 'Failed',
shield: false,
},
{
name: 'security revoke',
item: makeItem({
id: 6,
action: 'api_key.revoke',
category: 'api_keys',
actor: user(2, 'Jonas Weber'),
target: target('api_key', '71', 'Mobile app'),
security: true,
}),
actor: 'Jonas Weber',
sentence: 'Revoked API key Mobile app',
badge: 'Succeeded',
shield: true,
},
]
it.each(cases)('$name', async ({ item, actor, sentence, badge, shield }) => {
await mountActivity({ list: () => pageOf([item]) })
const row = rows()[0]!
const cells = row.querySelectorAll('[role="cell"]')
expect(textOf(cells[1])).toContain(actor.split(' ')[0])
if (actor === 'Not signed in') {
expect(textOf(cells[1])).toBe('Not signed in')
}
expect(textOf(row.querySelector('[data-testid="activity-sentence"]'))).toBe(sentence)
const strong = Array.from(
row.querySelectorAll('[data-testid="activity-sentence"] .font-semibold'),
).map(textOf)
const name = item.action === 'auth.login_failed' ? item.actor.name : item.target?.name
expect(strong).toEqual(name ? [name] : [])
expect(textOf(cells[cells.length - 1])).toBe(badge)
expect(!!row.querySelector('[aria-label="Security"]')).toBe(shield)
expect(row.className.includes('shadow-flag')).toBe(shield)
})
it('shows the actor initials, the key and the system badge', async () => {
await mountActivity({ list: () => pageOf(activityItems) })
const actorCells = Array.from(
document.body.querySelectorAll('[data-testid="activity-actor"]'),
)
expect(textOf(actorCells[0])).toBe('MAMarta Alves')
const keyCell = actorCells.find((cell) => textOf(cell).includes('Website sync'))!
expect(textOf(keyCell)).toContain('API key')
await expectIcon(keyCell, 'key-round')
const systemCell = actorCells.find((cell) => textOf(cell).startsWith('System'))!
expect(textOf(systemCell)).toBe('SystemSystem')
await expectIcon(systemCell, 'cog')
const anonymousCell = actorCells.find((cell) => textOf(cell) === 'Not signed in')!
await expectIcon(anonymousCell, 'user-round-x')
})
})
describe('Rows: targets', () => {
it('strikes a deleted target through with a Deleted badge and links a living one', async () => {
await mountActivity({
list: () =>
pageOf([
makeItem({
id: 1,
action: 'media.delete',
category: 'media',
target: target('image', '9', 'old-banner.png', false),
}),
makeItem({ id: 2 }),
]),
})
const targets = Array.from(
document.body.querySelectorAll('[data-testid="activity-target"]'),
)
expect(targets[0]!.querySelector('.line-through')?.textContent).toBe('old-banner.png')
expect(textOf(targets[0])).toContain('Deleted')
expect(textOf(targets[0])).toContain('Image')
const link = targets[1]!.querySelector('a')!
expect(link.getAttribute('href')).toBe('/article/8/edit')
expect(textOf(targets[1])).toContain('Article')
})
it('navigates from the target link without opening the details', async () => {
await mountActivity()
const link = document.body.querySelector<HTMLAnchorElement>(
'[data-testid="activity-row"] a[href="/article/8/edit"]',
)!
link.click()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/article/8/edit'))
expect(document.body.querySelector('[data-testid="activity-details"]')).toBeNull()
})
it.each([
['article', '8', '/article/8/edit'],
['image', '4', '/media'],
['user', '3', '/users'],
['invite', '2', '/users'],
['api_key', '71', '/integration/api-keys'],
])('links a %s target to %s', async (type, id, href) => {
await mountActivity({
list: () => pageOf([makeItem({ id: 1, target: target(type, id, 'Some name') })]),
})
expect(
document.body.querySelector('[data-testid="activity-target"] a')?.getAttribute('href'),
).toBe(href)
})
it('shows a target type with no page as plain text', async () => {
await mountActivity({
list: () =>
pageOf([
makeItem({
id: 1,
action: 'system.update_downloaded',
category: 'system',
actor: system,
target: target('release', 'v1.4.0', 'v1.4.0'),
}),
]),
})
const cell = document.body.querySelector('[data-testid="activity-target"]')!
expect(cell.querySelector('a')).toBeNull()
expect(textOf(cell)).toBe('v1.4.0Release')
})
it('shows a muted dash when there is no target', async () => {
await mountActivity({
list: () => pageOf([makeItem({ id: 1, target: null, action: 'auth.login' })]),
})
const cell = document.body.querySelector(
'[data-testid="activity-row"] [role="cell"]:nth-child(4)',
)!
expect(textOf(cell)).toBe('—')
expect(cell.querySelector('.text-muted')).toBeTruthy()
})
it('renders an unknown action code as it is and still opens the details', async () => {
await mountActivity({
list: () => pageOf([makeItem({ id: 1, action: 'article.archive', target: null })]),
})
const row = rows()[0]!
expect(textOf(row.querySelector('[data-testid="activity-sentence"]'))).toBe(
'article.archive',
)
row.click()
await flushPromises()
expect(document.body.querySelector('[data-testid="activity-details"]')).toBeTruthy()
})
})
describe('Day grouping', () => {
it('groups by local day in API order under separators', async () => {
await mountActivity({
list: () =>
pageOf([
makeItem({ id: 1, created_at: '2026-10-03T12:30:00Z' }),
makeItem({ id: 2, created_at: '2026-10-03T12:10:00Z' }),
makeItem({ id: 3, created_at: '2026-10-02T12:40:00Z' }),
]),
})
expect(days()).toEqual(['3 Oct 2026', '2 Oct 2026'])
const order = Array.from(
document.body.querySelectorAll(
'[data-testid="activity-day"], [data-testid="activity-row"]',
),
).map((element) => element.getAttribute('data-activity-id') ?? textOf(element))
expect(order).toEqual(['3 Oct 2026', '1', '2', '2 Oct 2026', '3'])
})
})
describe('Loading: skeletons after 150 ms', () => {
it('draws nothing in place for 150 ms, then 4 skeleton cards and 9 skeleton rows', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const never = new Promise(() => undefined)
await mountActivity({ list: () => never, summary: () => never })
expect(document.body.querySelector('h1')?.textContent).toBe('Activity')
expect(findButton('Export CSV')?.disabled).toBe(false)
expect(
document.body
.querySelector('[data-testid="activity-filter-bar"] input')
?.getAttribute('aria-label'),
).toBe('Search activity')
expect(document.body.querySelector('[data-testid="activity-strip-skeleton"]')).toBeNull()
expect(document.body.querySelector('[data-testid="activity-skeleton-row"]')).toBeNull()
vi.advanceTimersByTime(149)
await flushPromises()
expect(document.body.querySelector('[data-testid="activity-skeleton-row"]')).toBeNull()
vi.advanceTimersByTime(1)
await flushPromises()
expect(
document.body.querySelectorAll('[data-testid="activity-strip-skeleton"]'),
).toHaveLength(4)
expect(
document.body.querySelectorAll('[data-testid="activity-skeleton-row"]'),
).toHaveLength(9)
expect(document.body.querySelector('[role="table"]')?.getAttribute('aria-busy')).toBe(
'true',
)
expect(document.body.querySelector('[data-testid="activity-pager"]')).toBeNull()
})
it('draws 5 skeleton cards below md and replaces them with the data on resolve', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
let resolveList: (value: unknown) => void = () => undefined
const pending = new Promise((resolve) => (resolveList = resolve))
await mountActivity({ desktop: false, list: () => pending })
vi.advanceTimersByTime(150)
await flushPromises()
expect(
document.body.querySelectorAll('[data-testid="activity-skeleton-card"]'),
).toHaveLength(5)
resolveList(pageOf(activityItems))
await flushPromises()
expect(
document.body.querySelectorAll('[data-testid="activity-skeleton-card"]'),
).toHaveLength(0)
expect(rows()).toHaveLength(activityItems.length)
})
})
describe('Strip', () => {
it('shows a calm 0 for a quiet period', async () => {
await mountActivity({ summary: () => summaryZero })
const texts = stripTexts()
expect(texts[0]).toContain('214')
expect(texts[0]).toContain('4% vs previous 7 days')
await expectIcon(stripCards()[0], 'trending-down')
expect(texts[1]).toContain('5')
expect(texts[1]).toContain('4 people, 1 API key')
expect(texts[2]).toContain('Nothing failed')
expect(texts[3]).toContain('Nothing to flag')
await expectIcon(stripCards()[2], 'circle-check')
await expectIcon(stripCards()[3], 'circle-check')
expect(stripCards()[3]!.className).not.toContain('rc-warning-soft')
expect(stripCards()[2]!.className).not.toContain('rc-error-soft')
const bars = stripCards()[2]!.querySelectorAll<HTMLElement>(
'[data-testid="activity-bars"] span',
)
expect(Array.from(bars).map((bar) => bar.style.height)).toEqual(Array(7).fill('2px'))
})
it.each([
[40, 0, 'No earlier data'],
[40, 40, 'No change'],
])('reads the Total sub-line for %i against %i as %s', async (total, previous, subline) => {
await mountActivity({
summary: () => ({ ...summaryDefault, total, previous_total: previous }),
})
expect(stripTexts()[0]).toContain(subline)
await expectIcon(stripCards()[0], 'trending-up', false)
await expectIcon(stripCards()[0], 'trending-down', false)
})
it('sizes the bars from the series on desktop and mobile', async () => {
await mountActivity()
const heights = Array.from(
stripCards()[0]!.querySelectorAll<HTMLElement>('[data-testid="activity-bars"] span'),
).map((bar) => bar.style.height)
expect(heights).toEqual(['9px', '13px', '11px', '18px', '16px', '20px', '13px'])
unmountActivity()
await mountActivity({ desktop: false })
const mobile = Array.from(
stripCards()[0]!.querySelectorAll<HTMLElement>('[data-testid="activity-bars"] span'),
).map((bar) => bar.style.height)
expect(mobile).toEqual(['7px', '11px', '9px', '14px', '12px', '16px', '11px'])
})
it('is not drawn when the summary fails, and the list still works', async () => {
for (const failure of [new ApiError(500), new ApiError(403), new NetworkError()]) {
await mountActivity({
summary: () => {
throw failure
},
})
expect(stripCards()).toHaveLength(0)
expect(document.body.querySelector('[data-testid="activity-strip"]')).toBeNull()
expect(document.body.querySelector('[role="alert"]')).toBeNull()
expect(rows()).toHaveLength(activityItems.length)
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
'Showing 1–25 of 1,284',
)
unmountActivity()
}
})
})
describe('Empty states', () => {
it('shows No activity yet with Export disabled and no table or pager', async () => {
await mountActivity({ list: () => pageOf([], { total: 0 }), summary: () => summaryNone })
expect(pageText()).toContain('No activity yet')
expect(pageText()).toContain(
'Actions on articles, media, users and API keys will appear here.',
)
await expectIcon(document.body.querySelector('[data-slot="root"]'), 'history')
expect(findButton('Export CSV')?.disabled).toBe(true)
expect(document.body.querySelector('[role="table"]')).toBeNull()
expect(document.body.querySelector('[data-testid="activity-pager"]')).toBeNull()
const texts = stripTexts()
expect(texts[0]).toContain('No data yet')
expect(texts[1]).toContain('No data yet')
expect(texts[2]).toContain('Nothing failed')
expect(texts[3]).toContain('Nothing to flag')
})
it('offers Clear filters when filters match nothing and resets them', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
let empty = false
await mountActivity({
list: () => (empty ? pageOf([], { total: 0 }) : pageOf(activityItems)),
})
empty = true
const search = document.body.querySelector<HTMLInputElement>('input[type="text"]')!
search.value = 'sitemap'
search.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
vi.advanceTimersByTime(300)
await flushPromises()
expect(chipTexts()).toEqual(['Search: sitemap'])
expect(pageText()).toContain('No activity matches these filters')
expect(pageText()).toContain('Try a different search or a wider date range.')
expect(findButton('Export CSV')?.disabled).toBe(false)
expect(callsTo(SUMMARY_PATH)).toHaveLength(1)
empty = false
await clickButton('Clear filters')
expect(queryObject(callsTo(LIST_PATH).at(-1))).toEqual({
range: '7d',
page: '1',
size: '25',
})
expect(rows()).toHaveLength(activityItems.length)
expect(chipTexts()).toEqual([])
})
})
describe('Error with retry', () => {
it.each([
['a 500 response', () => new ApiError(500)],
['a 400 response', () => new ApiError(400)],
['a network failure', () => new NetworkError()],
])('replaces the table with an alert after %s and retries', async (_, failure) => {
let failing = true
await mountActivity({
list: () => {
if (failing) throw failure()
return pageOf(activityItems)
},
})
expect(stripCards()).toHaveLength(4)
expect(
document.body
.querySelector('[data-testid="activity-filter-bar"] input')
?.getAttribute('aria-label'),
).toBe('Search activity')
const alert = document.body.querySelector('[role="alert"]')!
expect(textOf(alert)).toContain("Couldn't load activity")
expect(textOf(alert)).toContain('Check your connection and try again.')
expect(findButton('Export CSV')?.disabled).toBe(true)
expect(document.body.querySelector('[role="table"]')).toBeNull()
expect(document.body.querySelector('[data-testid="activity-pager"]')).toBeNull()
failing = false
await clickButton('Try again')
expect(callsTo(LIST_PATH)).toHaveLength(2)
expect(queryObject(callsTo(LIST_PATH)[1])).toEqual(queryObject(callsTo(LIST_PATH)[0]))
expect(rows()).toHaveLength(activityItems.length)
expect(document.body.querySelector('[role="alert"]')).toBeNull()
})
})
describe('Forbidden (403)', () => {
it('shows only the header and the lock panel, and goes home', async () => {
await mountActivity({
list: () => {
throw new ApiError(403)
},
})
expect(textOf(document.body.querySelector('h1'))).toBe('Activity')
expect(findButton('Export CSV')).toBeUndefined()
expect(stripCards()).toHaveLength(0)
expect(document.body.querySelector('[data-testid="activity-filter-bar"]')).toBeNull()
expect(document.body.querySelector('[data-testid="activity-chips"]')).toBeNull()
expect(document.body.querySelector('[role="table"]')).toBeNull()
expect(document.body.querySelector('[data-testid="activity-pager"]')).toBeNull()
await expectIcon(document.body.querySelector('main, #dashboard-content'), 'lock')
expect(pageText()).toContain("You can't view this activity")
expect(pageText()).toContain(
"Your role doesn't include this page. Ask an owner or admin if you need access.",
)
await clickButton('Back to home')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'))
})
it('shows no panel for a 401, which the API client handles globally', async () => {
await mountActivity({
list: () => {
throw new ApiError(401)
},
})
expect(pageText()).not.toContain("You can't view this activity")
expect(pageText()).not.toContain("Couldn't load activity")
expect(document.body.querySelector('[role="table"]')).toBeNull()
})
it('shows no panel for a 403 on an unverified session', async () => {
await mountActivity({
verified: false,
list: () => {
throw new ApiError(403)
},
})
expect(callsTo(LIST_PATH)).toHaveLength(0)
expect(pageText()).not.toContain("You can't view this activity")
})
})
@@ -0,0 +1,384 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { initializeLocale, setLocale } from '@/i18n'
import { useSessionStore } from '@/stores/session'
import type { ActivityItem } from '@/lib/activity'
import HomeView from '../HomeView.vue'
import { anonymous, makeItem, target } from './activityFixtures'
const ACTIVITY_ROUTE = 'GET /v1/activity?size=5'
const ARTICLES_ROUTE = 'GET /v1/article/?format=html'
type Handler = () => Response | Promise<Response>
function response(status: number, body: unknown = null) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
} as unknown as Response
}
function page(items: ActivityItem[], total = items.length) {
return response(200, { items, total, page: 1, size: 5 })
}
function recentItems(count: number) {
const created = new Date(Date.now() - 30_000).toISOString()
return Array.from({ length: count }, (_, index) =>
makeItem({ id: index + 1, created_at: created }),
)
}
const articles = [
{
id: 1,
title: 'First article',
body: '',
images: [],
is_online: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-06T00:00:00Z',
},
]
let wrapper: Awaited<ReturnType<typeof mountHome>> | null = null
let calls: string[] = []
async function mountHome(role: 'owner' | 'admin' | undefined, handlers: Record<string, Handler>) {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', role }
session.status = 'ready'
calls = []
global.fetch = vi.fn((url: string, init?: RequestInit) => {
const key = `${init?.method ?? 'GET'} ${url}`
calls.push(key)
const handler = handlers[key]
if (!handler) {
return Promise.reject(new TypeError(`Unexpected fetch call: ${key}`))
}
return Promise.resolve(handler())
}) as unknown as typeof fetch
await router.push('/home')
const mounted = mount(HomeView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
wrapper = mounted
await flushPromises()
return mounted
}
function card() {
return wrapper!.find('[data-testid="recent-activity-card"]')
}
function rows() {
return wrapper!.findAll('[data-testid="recent-activity-row"]')
}
afterEach(async () => {
wrapper?.unmount()
wrapper = null
vi.useRealTimers()
localStorage.clear()
initializeLocale()
await router.push('/login')
})
describe('Home, owner or admin, the last five events with the actor name', () => {
it.each(['owner', 'admin'] as const)('shows five rows with names for %s', async (role) => {
await mountHome(role, {
[ACTIVITY_ROUTE]: () => page(recentItems(5)),
[ARTICLES_ROUTE]: () => response(200, articles),
})
expect(card().find('h2').text()).toBe('Recent activity')
const viewAll = card().find('a[href="/activity"]')
expect(viewAll.text()).toBe('View all')
expect(rows()).toHaveLength(5)
const first = rows()[0]!
expect(first.find('[data-testid="activity-sentence"]').text()).toBe(
'Published the article Spring release',
)
expect(first.find('[data-testid="activity-sentence"] .font-semibold').text()).toBe(
'Spring release',
)
expect(first.find('[data-testid="recent-activity-meta"]').text()).toMatch(
/^Marta Alves · .+/,
)
expect(first.text()).toContain('MA')
expect(calls.filter((call) => call === ACTIVITY_ROUTE)).toHaveLength(1)
expect(calls.some((call) => call.includes('actor'))).toBe(false)
expect(calls.some((call) => call.includes('summary') || call.includes('export'))).toBe(
false,
)
})
})
describe('Home, regular user, own events only and no actor name', () => {
it.each([['editor' as const], [undefined]])('shows only the time for %s', async (role) => {
await mountHome(role as undefined, {
[ACTIVITY_ROUTE]: () => page(recentItems(3)),
[ARTICLES_ROUTE]: () => response(200, articles),
})
expect(card().find('h2').text()).toBe('Your recent activity')
expect(rows()).toHaveLength(3)
const meta = rows()[0]!.find('[data-testid="recent-activity-meta"]').text()
expect(meta).not.toContain('Marta')
expect(meta).not.toContain('·')
})
})
describe('Home rows, failure and security markers', () => {
it('shows the badge and the shield as the outcome and security dictate', async () => {
const items = [
makeItem({ id: 1 }),
makeItem({ id: 2, outcome: 'failed' }),
makeItem({ id: 3, security: true }),
makeItem({ id: 4, outcome: 'failed', security: true }),
]
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(items),
[ARTICLES_ROUTE]: () => response(200, articles),
})
const marks = rows().map((row) => ({
badge: row.text().includes('Failed'),
shield: row.find('[data-testid="recent-activity-security"]').exists(),
}))
expect(marks).toEqual([
{ badge: false, shield: false },
{ badge: true, shield: false },
{ badge: false, shield: true },
{ badge: true, shield: false },
])
})
})
describe('Home rows, unknown action, failed login and not interactive', () => {
it('renders an unknown action and a failed login without throwing', async () => {
const items = [
makeItem({ id: 1, action: 'article.archive', target: null }),
makeItem({
id: 2,
action: 'auth.login_failed',
category: 'auth',
actor: anonymous('alex@example.com'),
target: null,
outcome: 'failed',
}),
]
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(items),
[ARTICLES_ROUTE]: () => response(200, articles),
})
expect(rows()[0]!.text()).toContain('article.archive')
expect(rows()[1]!.text()).toContain('Failed login attempt for alex@example.com')
expect(rows()[1]!.text()).toContain('Failed')
})
it('has no button or link inside a row and keeps the route on a click', async () => {
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(recentItems(2)),
[ARTICLES_ROUTE]: () => response(200, articles),
})
const row = rows()[0]!
await row.trigger('click')
expect(row.find('a, button').exists()).toBe(false)
expect(router.currentRoute.value.path).toBe('/home')
})
it('clamps a long sentence to two lines', async () => {
const items = [makeItem({ id: 1, target: target('article', '8', 'T'.repeat(150)) })]
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(items),
[ARTICLES_ROUTE]: () => response(200, articles),
})
expect(rows()[0]!.find('.line-clamp-2').exists()).toBe(true)
expect(rows()[0]!.find('[data-testid="recent-activity-meta"]').classes()).toContain(
'truncate',
)
})
})
describe('Home card, skeleton rows only after 150 ms', () => {
it('draws nothing, then five skeletons, then the rows', async () => {
vi.useFakeTimers()
let resolve: (() => void) | null = null
await mountHome('owner', {
[ACTIVITY_ROUTE]: () =>
new Promise((done) => {
resolve = () => done(page(recentItems(5)))
}),
[ARTICLES_ROUTE]: () => response(200, articles),
})
expect(card().attributes('aria-busy')).toBeUndefined()
expect(wrapper!.findAll('[data-testid="recent-activity-skeleton"]')).toHaveLength(0)
await vi.advanceTimersByTimeAsync(150)
expect(card().attributes('aria-busy')).toBe('true')
expect(wrapper!.findAll('[data-testid="recent-activity-skeleton"]')).toHaveLength(5)
resolve?.()
await vi.advanceTimersByTimeAsync(0)
await flushPromises()
expect(card().attributes('aria-busy')).toBeUndefined()
expect(rows()).toHaveLength(5)
})
it('never shows a skeleton for a fast response', async () => {
vi.useFakeTimers()
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(recentItems(5)),
[ARTICLES_ROUTE]: () => response(200, articles),
})
await vi.advanceTimersByTimeAsync(200)
expect(wrapper!.findAll('[data-testid="recent-activity-skeleton"]')).toHaveLength(0)
expect(rows()).toHaveLength(5)
})
})
describe('Home card, error shows a retryable alert that leaves the page alone', () => {
it.each([
['500', () => response(500)],
['403', () => response(403)],
['network', () => Promise.reject(new TypeError('offline'))],
])('shows the alert on %s and recovers on Try again', async (_name, failure) => {
let attempt = 0
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => {
attempt += 1
return attempt === 1 ? (failure() as Response) : page(recentItems(2))
},
[ARTICLES_ROUTE]: () => response(200, articles),
})
const alert = card().find('[role="alert"]')
expect(alert.text()).toContain("Couldn't load recent activity")
expect(wrapper!.find('[data-testid="stat-tile"]').exists()).toBe(true)
expect(wrapper!.text()).toContain('Connect your website')
const retry = alert.findAll('button').find((button) => button.text() === 'Try again')
await retry!.trigger('click')
await flushPromises()
expect(calls.filter((call) => call === ACTIVITY_ROUTE)).toHaveLength(2)
expect(card().find('[role="alert"]').exists()).toBe(false)
expect(rows()).toHaveLength(2)
})
})
describe('Home card, empty when there are no events yet', () => {
it.each([
['owner' as const, 'Actions on your site will appear here.'],
[undefined, 'Your actions will appear here.'],
])('says so for %s', async (role, text) => {
await mountHome(role as 'owner', {
[ACTIVITY_ROUTE]: () => page([], 0),
[ARTICLES_ROUTE]: () => response(200, articles),
})
const empty = card().find('[data-testid="recent-activity-empty"]')
expect(empty.text()).toContain('No activity yet')
expect(empty.text()).toContain(text)
expect(card().find('a[href="/activity"]').text()).toBe('View all')
})
})
describe('Home card and article list are independent', () => {
it('shows the articles alert and the activity rows together', async () => {
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(recentItems(5)),
[ARTICLES_ROUTE]: () => response(500),
})
expect(wrapper!.text()).toContain("Couldn't load your articles")
expect(rows()).toHaveLength(5)
})
it('shows the articles empty state and the card error together', async () => {
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => response(500),
[ARTICLES_ROUTE]: () => response(200, []),
})
expect(wrapper!.text()).toContain('Write your first article')
expect(card().text()).toContain("Couldn't load recent activity")
})
})
describe('Home layout is two columns from lg and stacked below', () => {
it('uses the lg two-column grid with the 420 px column and no max-width cap', async () => {
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(recentItems(1)),
[ARTICLES_ROUTE]: () => response(200, articles),
})
const grid = card().element.parentElement!
expect(grid.className).toContain('lg:grid-cols-[minmax(0,1fr)_--spacing(105)]')
expect(grid.className).toContain('lg:items-start')
expect(grid.className).toContain('gap-4')
const capped = [grid, grid.parentElement!].some((element) =>
/(^|\s)max-w-/.test(element.className),
)
expect(capped).toBe(false)
expect(card().classes()).toContain('lg:order-2')
})
})
describe('Home card, the request URL', () => {
it('asks for exactly /v1/activity?size=5 and nothing else', async () => {
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(recentItems(5)),
[ARTICLES_ROUTE]: () => response(200, articles),
})
const activityCalls = calls.filter((call) => call.includes('/v1/activity'))
expect(activityCalls).toEqual(['GET /v1/activity?size=5'])
})
})
describe('Home card, "View all" opens the activity page', () => {
it('navigates to /activity', async () => {
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(recentItems(1)),
[ARTICLES_ROUTE]: () => response(200, articles),
})
const push = vi.spyOn(router, 'push')
await card().find('a[href="/activity"]').trigger('click')
expect(push).toHaveBeenCalledWith('/activity')
push.mockRestore()
})
})
describe('Home card, the French interface', () => {
it('titles and words the card in French', async () => {
setLocale('fr')
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page([], 0),
[ARTICLES_ROUTE]: () => response(200, articles),
})
expect(card().find('h2').text()).toBe('Activité récente')
expect(card().text()).toContain('Tout voir')
expect(card().text()).toContain('Aucune activité pour le moment')
})
})
+5 -1
View File
@@ -107,7 +107,11 @@ async function mountHome(handlers: Record<string, () => Response | Promise<Respo
const session = useSessionStore() const session = useSessionStore()
session.session = { verified: true, username: 'marta' } session.session = { verified: true, username: 'marta' }
session.status = 'ready' session.status = 'ready'
global.fetch = fetchRouter(handlers) as unknown as typeof fetch global.fetch = fetchRouter({
'GET /v1/activity?size=5': () =>
Promise.resolve(mockResponse(200, { items: [], total: 0, page: 1, size: 5 })),
...handlers,
}) as unknown as typeof fetch
await router.push('/home') await router.push('/home')
const wrapper = mount(HomeView, { const wrapper = mount(HomeView, {
attachTo: document.body, attachTo: document.body,
@@ -1503,6 +1503,7 @@ describe('Sidebar', () => {
'/article', '/article',
'/media', '/media',
'/users', '/users',
'/activity',
'/integration', '/integration',
'/settings', '/settings',
]) ])
+2 -2
View File
@@ -279,11 +279,11 @@ describe('Default: the usual first visit with no display name', () => {
expect(request).not.toHaveBeenCalled() expect(request).not.toHaveBeenCalled()
}) })
it('marks none of the six navigation items as the current page', async () => { it('marks none of the seven navigation items as the current page', async () => {
await mountApp() await mountApp()
const links = document.body.querySelectorAll('nav[aria-label="Main"] a') const links = document.body.querySelectorAll('nav[aria-label="Main"] a')
expect(links).toHaveLength(6) expect(links).toHaveLength(7)
links.forEach((link) => expect(link.getAttribute('aria-current')).toBeNull()) links.forEach((link) => expect(link.getAttribute('aria-current')).toBeNull())
}) })
@@ -0,0 +1,478 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import USelectMenu from '@nuxt/ui/components/SelectMenu.vue'
import router from '@/router'
import { initializeLocale, setLocale } from '@/i18n'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import type { WorkspaceSettings } from '@/composables/useSettings'
import { useSessionStore } from '@/stores/session'
import SettingsView from '../SettingsView.vue'
import { DEFAULT_PURGE_SETTINGS, PURGE_SETTINGS_PATH } from './updateTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return {
...actual,
useApi: vi.fn(),
}
})
const SETTINGS_PATH = '/v1/settings/'
const SAVED_VISIBLE_MS = 2000
function at(day: number, hour: number) {
return new Date(2026, 9, day, hour, 0).toISOString()
}
function settings(overrides: Partial<WorkspaceSettings> = {}): WorkspaceSettings {
return {
version: 'v2.2.0-beta',
activity_retention_days: 90,
activity_last_cleanup_at: at(6, 3),
activity_last_cleanup_removed: 142,
activity_next_cleanup_at: at(7, 3),
...overrides,
}
}
let requestMock: ReturnType<typeof vi.fn>
let getMeMock: ReturnType<typeof vi.fn>
let wrapper: VueWrapper | null = null
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
async function mountPage(
role: 'owner' | 'admin' | 'editor' | undefined,
load: () => Promise<unknown>,
patch: (body: unknown) => Promise<unknown> = () => Promise.resolve(settings()),
) {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', id: 1, role }
session.status = 'ready'
requestMock = vi.fn((path: string, init?: RequestInit) => {
if (path === SETTINGS_PATH && init?.method === 'PATCH') {
return patch(JSON.parse(init.body as string))
}
if (path === SETTINGS_PATH) {
return load()
}
if (path === PURGE_SETTINGS_PATH) {
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
}
throw new Error(`Unexpected request: ${path}`)
})
getMeMock = vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1, role })
vi.mocked(useApi).mockReturnValue({ getMe: getMeMock, request: requestMock })
await router.push('/settings')
wrapper = mount(SettingsView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return wrapper
}
function retention() {
return document.body.querySelector('[data-testid="retention-card"]')
}
function text() {
return retention()?.textContent?.replace(/\s+/g, ' ') ?? ''
}
function line(testId: string) {
return document.body.querySelector(`[data-testid="${testId}"]`)?.textContent?.trim()
}
function patches() {
return requestMock.mock.calls.filter(([, init]) => init?.method === 'PATCH')
}
function settingsGets() {
return requestMock.mock.calls.filter(
([path, init]) => path === SETTINGS_PATH && init?.method !== 'PATCH',
)
}
function button(label: string) {
return Array.from(document.body.querySelectorAll('button')).find(
(candidate) => candidate.textContent?.trim() === label,
)
}
async function choose(days: number) {
wrapper!.findComponent(USelectMenu).vm.$emit('update:modelValue', days)
await flushPromises()
}
function selectedLabel() {
const select = wrapper!.findComponent(USelectMenu)
const value = select.props('modelValue')
return (select.props('items') as { value: number; label: string }[]).find(
(item) => item.value === value,
)?.label
}
afterEach(async () => {
wrapper?.unmount()
wrapper = null
vi.useRealTimers()
localStorage.clear()
initializeLocale()
await router.push('/login')
document.body.innerHTML = ''
})
describe('Settings retention is hidden for everyone but the owner', () => {
it.each([['admin' as const], ['editor' as const], [undefined]])(
'renders nothing for %s',
async (role) => {
await mountPage(role, () => Promise.resolve(settings()))
expect(retention()).toBeNull()
expect(document.body.textContent).not.toContain('Keep activity for')
expect(document.body.textContent).toContain('Media cleanup')
expect(settingsGets()).toHaveLength(1)
},
)
})
describe('Settings retention, Default with 90 days', () => {
it('shows the header, the select, the helper, the lock alert and the lines', async () => {
await mountPage('owner', () => Promise.resolve(settings()))
expect(text()).toContain('Retention')
expect(text()).toContain('How long activity is kept.')
expect(selectedLabel()).toBe('90 days')
expect(text()).toContain('Older events are removed automatically.')
expect(text()).toContain('Activity logs are append-only')
expect(line('retention-last')).toBe('6 Oct 2026, 03:00 · 142 events removed')
expect(line('retention-next')).toBe('7 Oct 2026, 03:00')
expect(text()).not.toContain('Saved')
expect(patches()).toHaveLength(0)
expect(settingsGets()).toHaveLength(1)
})
it('reads Never before the first cleanup', async () => {
await mountPage('owner', () =>
Promise.resolve(settings({ activity_last_cleanup_at: null })),
)
expect(line('retention-last')).toBe('Never')
expect(line('retention-next')).toBe('7 Oct 2026, 03:00')
})
it('reads Never and Not scheduled for Forever when never cleaned', async () => {
await mountPage('owner', () =>
Promise.resolve(
settings({
activity_retention_days: 0,
activity_last_cleanup_at: null,
activity_next_cleanup_at: null,
}),
),
)
expect(selectedLabel()).toBe('Forever')
expect(line('retention-last')).toBe('Never')
expect(line('retention-next')).toBe('Not scheduled')
expect(text()).toContain('Nothing is removed automatically. The log grows over time.')
})
})
describe('Settings retention, skeleton and load failure', () => {
it('draws nothing in the body for 150 ms, then skeleton lines', async () => {
vi.useFakeTimers()
const pending = deferred<unknown>()
await mountPage('owner', () => pending.promise)
expect(retention()?.getAttribute('aria-busy')).toBeNull()
expect(text()).not.toContain('Keep activity for')
await vi.advanceTimersByTimeAsync(150)
expect(retention()?.getAttribute('aria-busy')).toBe('true')
expect(text()).not.toContain('Keep activity for')
})
it('shows the shared alert and re-runs the shared load', async () => {
let attempt = 0
await mountPage('owner', () => {
attempt += 1
return attempt === 1 ? Promise.reject(new ApiError(500)) : Promise.resolve(settings())
})
expect(text()).toContain("Couldn't reach RenewCMS. Check your connection and try again.")
const inCard = Array.from(retention()!.querySelectorAll('button')).find(
(candidate) => candidate.textContent?.trim() === 'Try again',
)
inCard!.click()
await flushPromises()
expect(settingsGets()).toHaveLength(2)
expect(selectedLabel()).toBe('90 days')
})
})
describe('Settings retention, a longer period saves at once', () => {
it.each([
[90, 365],
[30, 90],
[90, 0],
])('saves %s to %s with one PATCH and shows Saved for 2 s', async (from, to) => {
vi.useFakeTimers()
const pending = deferred<unknown>()
await mountPage(
'owner',
() => Promise.resolve(settings({ activity_retention_days: from })),
() => pending.promise,
)
await choose(to)
expect(patches()).toHaveLength(1)
expect(patches()[0]![1].body).toBe(JSON.stringify({ activity_retention_days: to }))
expect(text()).toContain('Saving…')
expect(text()).not.toContain('Events older than')
pending.resolve(
settings({
activity_retention_days: to,
activity_next_cleanup_at: to === 0 ? null : at(8, 3),
}),
)
await flushPromises()
expect(text()).toContain('Saved')
expect(line('retention-next')).toBe(to === 0 ? 'Not scheduled' : '8 Oct 2026, 03:00')
expect(line('retention-last')).toBe('6 Oct 2026, 03:00 · 142 events removed')
await vi.advanceTimersByTimeAsync(SAVED_VISIBLE_MS)
expect(text()).not.toContain('Saved')
})
})
describe('Settings retention, shortening asks first', () => {
it('warns without sending, then applies', async () => {
await mountPage(
'owner',
() => Promise.resolve(settings()),
() => Promise.resolve(settings({ activity_retention_days: 30 })),
)
await choose(30)
expect(patches()).toHaveLength(0)
expect(selectedLabel()).toBe('30 days')
expect(text()).toContain('Events older than 30 days will be removed')
expect(text()).toContain(
"This happens at the next cleanup on 7 Oct 2026, 03:00. It can't be undone.",
)
button('Apply 30 days')!.click()
await flushPromises()
expect(patches()).toHaveLength(1)
expect(patches()[0]![1].body).toBe(JSON.stringify({ activity_retention_days: 30 }))
expect(text()).not.toContain('Events older than')
expect(text()).toContain('Saved')
})
it('cancels back to the saved period without any request', async () => {
await mountPage('owner', () => Promise.resolve(settings()))
await choose(30)
button('Cancel')!.click()
await flushPromises()
expect(text()).not.toContain('Events older than')
expect(selectedLabel()).toBe('90 days')
expect(patches()).toHaveLength(0)
})
it.each([
[0, 365, true],
[365, 90, true],
[90, 90, false],
[30, 0, false],
])('saved %s then chosen %s warns: %s', async (saved, chosen, warns) => {
await mountPage(
'owner',
() => Promise.resolve(settings({ activity_retention_days: saved })),
() => Promise.resolve(settings({ activity_retention_days: chosen })),
)
await choose(chosen)
expect(text().includes('Events older than')).toBe(warns)
expect(patches()).toHaveLength(saved === chosen || warns ? 0 : 1)
})
it('drops the warning when a longer period is chosen', async () => {
await mountPage(
'owner',
() => Promise.resolve(settings()),
() => Promise.resolve(settings({ activity_retention_days: 365 })),
)
await choose(30)
await choose(365)
expect(text()).not.toContain('Events older than')
expect(patches()).toHaveLength(1)
expect(patches()[0]![1].body).toBe(JSON.stringify({ activity_retention_days: 365 }))
})
it('describes a shortening from Forever without a date', async () => {
await mountPage('owner', () =>
Promise.resolve(
settings({ activity_retention_days: 0, activity_next_cleanup_at: null }),
),
)
await choose(365)
expect(text()).toContain('Events older than 365 days will be removed')
expect(text()).toContain(
"Older events will be removed at the next cleanup. It can't be undone.",
)
})
})
describe('Settings retention, save error reverts the control and Try again retries', () => {
it.each([
['500', () => Promise.reject(new ApiError(500))],
['400', () => Promise.reject(new ApiError(400))],
['403', () => Promise.reject(new ApiError(403))],
['network', () => Promise.reject(new NetworkError())],
])('on %s', async (_name, failure) => {
let attempt = 0
await mountPage(
'owner',
() => Promise.resolve(settings()),
() => {
attempt += 1
return attempt === 1
? failure()
: Promise.resolve(settings({ activity_retention_days: 365 }))
},
)
await choose(365)
expect(text()).toContain("Couldn't save the retention period")
expect(text()).toContain('Your previous setting is still in place.')
expect(selectedLabel()).toBe('90 days')
expect(line('retention-next')).toBe('7 Oct 2026, 03:00')
expect(text()).not.toContain('Saved')
const retry = Array.from(retention()!.querySelectorAll('button')).find(
(candidate) => candidate.textContent?.trim() === 'Try again',
)
retry!.click()
await flushPromises()
expect(patches()).toHaveLength(2)
expect(patches()[1]![1].body).toBe(JSON.stringify({ activity_retention_days: 365 }))
expect(text()).toContain('Saved')
expect(text()).not.toContain("Couldn't save")
})
it('retries an applied shortening without asking again', async () => {
let attempt = 0
await mountPage(
'owner',
() => Promise.resolve(settings()),
() => {
attempt += 1
return attempt === 1
? Promise.reject(new ApiError(500))
: Promise.resolve(settings({ activity_retention_days: 30 }))
},
)
await choose(30)
button('Apply 30 days')!.click()
await flushPromises()
expect(text()).not.toContain('Events older than')
expect(text()).toContain("Couldn't save the retention period")
expect(selectedLabel()).toBe('90 days')
Array.from(retention()!.querySelectorAll('button'))
.find((candidate) => candidate.textContent?.trim() === 'Try again')!
.click()
await flushPromises()
expect(patches()).toHaveLength(2)
expect(text()).not.toContain('Events older than')
})
it('refreshes the session on a 403 and removes the card for a demoted owner', async () => {
await mountPage(
'owner',
() => Promise.resolve(settings()),
() => Promise.reject(new ApiError(403)),
)
getMeMock.mockResolvedValue({ verified: true, username: 'marta', id: 1, role: 'admin' })
await choose(365)
expect(getMeMock).toHaveBeenCalled()
expect(retention()).toBeNull()
})
it('shows no card alert on a 401', async () => {
await mountPage(
'owner',
() => Promise.resolve(settings()),
() => Promise.reject(new ApiError(401)),
)
await choose(365)
expect(text()).not.toContain("Couldn't save")
expect(selectedLabel()).toBe('90 days')
})
})
describe('Settings retention, offline disables the controls', () => {
it('disables the select and the warning buttons', async () => {
await mountPage('owner', () => Promise.resolve(settings()))
await choose(30)
const online = vi.spyOn(window.navigator, 'onLine', 'get').mockReturnValue(false)
window.dispatchEvent(new Event('offline'))
await flushPromises()
expect(wrapper!.findComponent(USelectMenu).props('disabled')).toBe(true)
expect(button('Apply 30 days')!.disabled).toBe(true)
expect(button('Cancel')!.disabled).toBe(true)
online.mockRestore()
})
})
describe('Settings retention, the French interface', () => {
it('words the card in French', async () => {
setLocale('fr')
await mountPage('owner', () => Promise.resolve(settings()))
expect(text()).toContain('Conservation')
expect(text()).toContain('Conserver l’activité pendant')
expect(text()).toContain('Dernier nettoyage')
expect(text()).toContain('Prochain nettoyage')
expect(selectedLabel()).toBe('90 jours')
})
})
+205
View File
@@ -0,0 +1,205 @@
import type {
ActivityActor,
ActivityChange,
ActivityItem,
ActivityPage,
ActivitySummary,
ActivityTarget,
} from '@/lib/activity'
const LONG_TITLE =
'The complete guide to restoring a nineteenth-century timber-framed farmhouse: structural surveys, lime mortar, breathable insulation and what it all costs'
const WEB_IP = '203.0.113.24'
const WEB_AGENT = 'Firefox 131 on macOS'
export const user = (id: number, name: string): ActivityActor => ({ type: 'user', id, name })
export const apiKey = (id: number, name: string): ActivityActor => ({
type: 'api_key',
id,
name,
})
export const system: ActivityActor = { type: 'system', id: null, name: 'System' }
export const anonymous = (name: string): ActivityActor => ({ type: 'anonymous', id: null, name })
export const target = (type: string, id: string, name: string, exists = true): ActivityTarget => ({
type,
id,
name,
exists,
})
export function makeItem(overrides: Partial<ActivityItem> & { id: number }): ActivityItem {
return {
created_at: '2026-10-03T12:30:00Z',
action: 'article.publish',
category: 'articles',
actor: user(3, 'Marta Alves'),
target: target('article', '8', 'Spring release'),
outcome: 'success',
failure_reason: null,
security: false,
source: 'web',
ip: WEB_IP,
user_agent: WEB_AGENT,
params: {},
changes: [],
...overrides,
}
}
const titleChange: ActivityChange = {
field: 'title',
kind: 'text',
before: 'Why we reuse floorboards',
after: 'Why we reuse original floorboards',
}
const bodyChange: ActivityChange = { field: 'body', kind: 'body', delta: 312 }
export const activityItems: ActivityItem[] = [
makeItem({
id: 1,
created_at: '2026-10-03T12:58:00Z',
changes: [{ field: 'online', kind: 'text', before: 'false', after: 'true' }],
}),
makeItem({
id: 2,
created_at: '2026-10-03T12:45:00Z',
action: 'article.edit',
target: target('article', '5', 'Why we reuse original floorboards'),
changes: [titleChange, bodyChange],
}),
makeItem({
id: 3,
created_at: '2026-10-03T12:40:00Z',
action: 'auth.login_failed',
category: 'auth',
actor: anonymous('alex@example.com'),
target: null,
outcome: 'failed',
failure_reason: 'The password did not match. 3 attempts in 10 minutes.',
security: true,
ip: '198.51.100.77',
user_agent: 'Chrome 129 on Windows',
}),
makeItem({
id: 4,
created_at: '2026-10-03T12:35:00Z',
action: 'api_key.revoke',
category: 'api_keys',
actor: user(2, 'Jonas Weber'),
target: target('api_key', '71', 'Mobile app'),
security: true,
}),
makeItem({
id: 5,
created_at: '2026-10-03T12:20:00Z',
action: 'system.media_purge',
category: 'system',
actor: system,
target: null,
source: 'system',
ip: null,
user_agent: null,
params: { merged: 3, articles_updated: 5 },
}),
makeItem({
id: 6,
created_at: '2026-10-03T12:10:00Z',
action: 'media.upload',
category: 'media',
actor: apiKey(7, 'Website sync'),
target: target('image', '4', 'north-wall.jpg'),
source: 'api_key',
}),
makeItem({
id: 7,
created_at: '2026-10-03T12:05:00Z',
action: 'media.delete',
category: 'media',
actor: user(3, 'Marta Alves'),
target: target('image', '9', 'old-banner.png', false),
}),
makeItem({
id: 8,
created_at: '2026-10-03T12:00:00Z',
outcome: 'failed',
failure_reason: 'The article has no cover image. Add one, then publish again.',
actor: user(4, 'Inès Duarte'),
target: target('article', '6', LONG_TITLE),
}),
makeItem({
id: 9,
created_at: '2026-10-02T12:50:00Z',
action: 'system.activity_cleanup',
category: 'system',
actor: system,
target: null,
source: 'system',
ip: null,
user_agent: null,
params: { removed: 3 },
}),
makeItem({
id: 10,
created_at: '2026-10-02T12:40:00Z',
action: 'user.update_profile',
category: 'users',
actor: user(4, 'Inès Duarte'),
target: target('user', '8', 'Sam Rivera'),
changes: [
{ field: 'display_name', kind: 'text', before: 'Sam Rivera', after: 'Samuel Rivera' },
],
}),
]
export const LONG_VALUE = 'Renovation notes and practical guides from the Renew studio. '.repeat(3)
export const TOTAL = 1284
export const SIZE = 25
export function pageOf(
items: ActivityItem[],
{ total = TOTAL, page = 1, size = SIZE }: { total?: number; page?: number; size?: number } = {},
): ActivityPage {
return { items, total, page, size }
}
export const summaryDefault: ActivitySummary = {
total: 1284,
previous_total: 1146,
active_people: { users: 7, api_keys: 2 },
failed: 6,
security: 11,
series: {
total: [4, 6, 5, 8, 7, 9, 6],
active: [5, 6, 6, 7, 7, 8, 9],
failed: [0, 1, 0, 2, 1, 0, 2],
security: [1, 0, 2, 1, 3, 2, 2],
},
}
export const summaryZero: ActivitySummary = {
total: 214,
previous_total: 223,
active_people: { users: 4, api_keys: 1 },
failed: 0,
security: 0,
series: {
total: [3, 5, 2, 6, 4, 1, 2],
active: [2, 3, 2, 3, 2, 1, 2],
failed: [0, 0, 0, 0, 0, 0, 0],
security: [0, 0, 0, 0, 0, 0, 0],
},
}
const ZEROS = [0, 0, 0, 0, 0, 0, 0]
export const summaryNone: ActivitySummary = {
total: 0,
previous_total: 0,
active_people: { users: 0, api_keys: 0 },
failed: 0,
security: 0,
series: { total: ZEROS, active: ZEROS, failed: ZEROS, security: ZEROS },
}
+206
View File
@@ -0,0 +1,206 @@
import { expect, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import { addCollection } from '@iconify/vue'
import { getIconData } from '@iconify/utils'
import lucide from 'virtual:lucide-subset'
import router from '@/router'
import { useApi } from '@/composables/useApi'
import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import ActivityView from '../ActivityView.vue'
import { activityItems, pageOf, summaryDefault } from './activityFixtures'
addCollection(lucide)
export type Responder = (query: URLSearchParams, call: number) => unknown
export interface ActivityApiMock {
list?: Responder
summary?: Responder
exportFile?: Responder
users?: Responder
keys?: Responder
}
export interface MountOptions extends ActivityApiMock {
role?: string
desktop?: boolean
verified?: boolean
}
export interface RecordedCall {
path: string
query: URLSearchParams
}
export const LIST_PATH = '/v1/activity'
export const SUMMARY_PATH = '/v1/activity/summary'
export const EXPORT_PATH = '/v1/activity/export'
export const USERS_PATH = '/v1/user/'
export const KEYS_PATH = '/v1/api-key/'
export const harness = {
calls: [] as RecordedCall[],
wrapper: null as VueWrapper | null,
}
const counters = new Map<string, number>()
function respond(responder: Responder | undefined, query: URLSearchParams, key: string) {
if (!responder) throw new Error(`Unexpected request: ${key}`)
const call = (counters.get(key) ?? 0) + 1
counters.set(key, call)
return responder(query, call)
}
export function callsTo(path: string) {
return harness.calls.filter((call) => call.path === path).map((call) => call.query)
}
export async function mountActivity(options: MountOptions = {}) {
const { desktop = true, verified = true } = options
const role = 'role' in options ? options.role : 'owner'
const api: ActivityApiMock = {
list: () => pageOf(activityItems),
summary: () => summaryDefault,
exportFile: () => new Blob(['when,actor'], { type: 'text/csv' }),
users: () => [],
keys: () => [],
...options,
}
harness.calls = []
counters.clear()
setViewportWidth(desktop ? DESKTOP_WIDTH : MOBILE_WIDTH)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = {
id: 2,
verified,
username: 'ines',
displayName: 'Inès Duarte',
role: role as 'owner' | undefined,
}
session.status = 'ready'
const record = (path: string) => {
const url = new URL(path, 'http://localhost')
harness.calls.push({ path: url.pathname, query: url.searchParams })
return url
}
const request = vi.fn(async (path: string) => {
const url = record(path)
const key = url.pathname
switch (key) {
case LIST_PATH:
return respond(api.list, url.searchParams, key)
case SUMMARY_PATH:
return respond(api.summary, url.searchParams, key)
case USERS_PATH:
return respond(api.users, url.searchParams, key)
case KEYS_PATH:
return respond(api.keys, url.searchParams, key)
default:
throw new Error(`Unexpected request: ${path}`)
}
})
const requestBlob = vi.fn(async (path: string) => {
const url = record(path)
return respond(api.exportFile, url.searchParams, url.pathname) as Blob
})
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified, role }),
request,
requestBlob,
} as unknown as ReturnType<typeof useApi>)
await router.push('/activity')
harness.wrapper = mount(ActivityView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return harness.wrapper
}
export function unmountActivity() {
harness.wrapper?.unmount()
harness.wrapper = null
document.body.innerHTML = ''
}
export function textOf(element: Element | null | undefined) {
return (element?.textContent ?? '').replace(/\s+/g, ' ').trim()
}
export function rows() {
return Array.from(
document.body.querySelectorAll<HTMLElement>(
'[data-testid="activity-row"], [data-testid="activity-card"]',
),
)
}
export function days() {
return Array.from(document.body.querySelectorAll('[data-testid="activity-day"]')).map(textOf)
}
export function findButton(text: string) {
return Array.from(document.body.querySelectorAll<HTMLButtonElement>('button')).find(
(button) => textOf(button) === text || button.getAttribute('aria-label') === text,
)
}
export async function click(element: Element | null | undefined) {
;(element as HTMLElement | undefined)?.click()
await flushPromises()
}
export async function clickButton(text: string) {
await click(findButton(text))
}
export function chipTexts() {
return Array.from(document.body.querySelectorAll('[data-testid="activity-chip"]')).map(textOf)
}
export function stripCards() {
return Array.from(
document.body.querySelectorAll<HTMLElement>('[data-testid="activity-strip-card"]'),
)
}
export async function setSearch(value: string) {
const input = document.body.querySelector<HTMLInputElement>('input[type="text"]')!
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
return input
}
export function lastListQuery() {
const queries = callsTo(LIST_PATH)
return queries[queries.length - 1]
}
export function queryObject(query: URLSearchParams | undefined) {
return Object.fromEntries(query?.entries() ?? [])
}
export { DESKTOP_WIDTH, MOBILE_WIDTH }
export function hasIcon(root: Element | null | undefined, name: string) {
const body = getIconData(lucide, name)?.body?.replace(/<(\w+)([^>]*?)\/>/g, '<$1$2></$1>')
if (!root || !body) return false
return Array.from(root.querySelectorAll('svg')).some((svg) => svg.innerHTML === body)
}
export async function expectIcon(root: Element | null | undefined, name: string, present = true) {
if (present) {
await vi.waitFor(() => expect(hasIcon(root, name)).toBe(true))
return
}
await new Promise((resolve) => setTimeout(resolve, 50))
expect(hasIcon(root, name)).toBe(false)
}
+484 -6
View File
@@ -165,6 +165,18 @@ const verifiedSession: ApiMock = {
} }
const updateRunningVersion = 'v2.2.0-beta' const updateRunningVersion = 'v2.2.0-beta'
const newCardAbsentReason = 'new card absent from board frame (dashed placeholder)'
const homeActivityMock: ApiMock = {
status: 200,
body: { items: [], total: 0, page: 1, size: 5 },
}
const workspaceSettingsBody = {
version: updateRunningVersion,
activity_retention_days: 90,
activity_last_cleanup_at: '2026-10-06T03:00:00Z',
activity_last_cleanup_removed: 142,
activity_next_cleanup_at: '2026-10-07T03:00:00Z',
}
const updateLatestTag = 'v2.3.0' const updateLatestTag = 'v2.3.0'
const updateReleaseUrl = 'https://git.floriansylvain.fr/fsylvain/RenewCMS/releases/tag/v2.3.0' const updateReleaseUrl = 'https://git.floriansylvain.fr/fsylvain/RenewCMS/releases/tag/v2.3.0'
const updateReleasedAt = '2026-09-24T08:00:00Z' const updateReleasedAt = '2026-09-24T08:00:00Z'
@@ -291,7 +303,7 @@ function updateApi(
): Record<string, ApiMock> { ): Record<string, ApiMock> {
return { return {
'GET /v1/auth/me': role === 'owner' ? verifiedSession : adminSession, 'GET /v1/auth/me': role === 'owner' ? verifiedSession : adminSession,
'GET /v1/settings/': { status: 200, body: { version: updateRunningVersion } }, 'GET /v1/settings/': { status: 200, body: workspaceSettingsBody },
'GET /v1/system/update': { status: 200, body: status }, 'GET /v1/system/update': { status: 200, body: status },
'POST /v1/system/update/check': check, 'POST /v1/system/update/check': check,
} }
@@ -545,6 +557,7 @@ const newArticleBody = 'Small rooms show every grout line, so we start with the
function newArticleHomeApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> { function newArticleHomeApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
return { return {
'GET /v1/auth/me': verifiedSession, 'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock,
'GET /v1/article/': { status: 200, body: sixArticlesFixture }, 'GET /v1/article/': { status: 200, body: sixArticlesFixture },
...overrides, ...overrides,
} }
@@ -2378,7 +2391,7 @@ function cleanupApi(
): Record<string, ApiMock> { ): Record<string, ApiMock> {
return { return {
'GET /v1/auth/me': sessionWithRole(role), 'GET /v1/auth/me': sessionWithRole(role),
'GET /v1/settings/': { status: 200, body: { version: updateRunningVersion } }, 'GET /v1/settings/': { status: 200, body: workspaceSettingsBody },
'GET /v1/system/update': { status: 200, hang: true }, 'GET /v1/system/update': { status: 200, hang: true },
[purgeSettingsPath]: settings, [purgeSettingsPath]: settings,
...overrides, ...overrides,
@@ -2392,8 +2405,9 @@ function settingsBody(body: unknown): ApiMock {
function cleanupState( function cleanupState(
api: Record<string, ApiMock>, api: Record<string, ApiMock>,
steps?: (page: Page) => Promise<void>, steps?: (page: Page) => Promise<void>,
exception?: DiffException,
): VisualState { ): VisualState {
return { path: '/settings', api, steps } return { path: '/settings', api, steps, exception }
} }
async function openCleanupDialog(page: Page) { async function openCleanupDialog(page: Page) {
@@ -2486,7 +2500,11 @@ const cleanupStates: Record<string, VisualState> = {
await page.getByText('Enter a whole number of days between 1 and 365.').waitFor() await page.getByText('Enter a whole number of days between 1 and 365.').waitFor()
}), }),
'Read-only (non-admin)': cleanupState(cleanupApi(settingsBody(purgeOnBody), {}, 'member')), 'Read-only (non-admin)': cleanupState(cleanupApi(settingsBody(purgeOnBody), {}, 'member')),
'Loading skeleton': cleanupState(cleanupApi({ status: 200, hang: true })), 'Loading skeleton': cleanupState(cleanupApi({ status: 200, hang: true }), undefined, {
maxPercent: 17.5,
reason: newCardAbsentReason,
approvedBy: 'fallen',
}),
Offline: cleanupState(cleanupApi(settingsBody(purgeOnBody)), goOffline), Offline: cleanupState(cleanupApi(settingsBody(purgeOnBody)), goOffline),
'Loading preview': cleanupState( 'Loading preview': cleanupState(
cleanupDialogApi({ [purgePreviewPath]: { status: 200, hang: true } }), cleanupDialogApi({ [purgePreviewPath]: { status: 200, hang: true } }),
@@ -2576,13 +2594,391 @@ const mediaPurgeStates: Record<string, VisualState> = {
'Offline: button disabled': mediaState(mediaDuplicatesApi(), goOffline, mediaDuplicatesPath), 'Offline: button disabled': mediaState(mediaDuplicatesApi(), goOffline, mediaDuplicatesPath),
} }
const activityOwnerSession: ApiMock = {
status: 200,
body: {
id: 2,
is_verified: true,
username: 'ines',
email: 'ines.duarte@renew.example',
display_name: 'Inès Duarte',
role: 'owner',
},
}
const activityEditorSession: ApiMock = {
status: 200,
body: {
id: 3,
is_verified: true,
username: 'marta',
email: 'marta@renew.example',
display_name: 'Marta Alves',
role: 'editor',
},
}
const activitySummaryDefault = {
total: 1284,
previous_total: 1146,
active_people: { users: 7, api_keys: 2 },
failed: 6,
security: 11,
series: {
total: [4, 6, 5, 8, 7, 9, 6],
active: [5, 6, 6, 7, 7, 8, 9],
failed: [0, 1, 0, 2, 1, 0, 2],
security: [1, 0, 2, 1, 3, 2, 2],
},
}
const activitySummaryNone = {
total: 0,
previous_total: 0,
active_people: { users: 0, api_keys: 0 },
failed: 0,
security: 0,
series: {
total: [0, 0, 0, 0, 0, 0, 0],
active: [0, 0, 0, 0, 0, 0, 0],
failed: [0, 0, 0, 0, 0, 0, 0],
security: [0, 0, 0, 0, 0, 0, 0],
},
}
const activityLongTitle =
'The complete guide to restoring a nineteenth-century timber-framed farmhouse: structural surveys, lime mortar, breathable insulation and what it all costs'
function activityEvent(
id: number,
createdAt: string,
action: string,
category: string,
actor: { type: string; id: number | null; name: string },
extra: Record<string, unknown> = {},
) {
return {
id,
created_at: createdAt,
action,
category,
actor,
target: null,
outcome: 'success',
failure_reason: null,
security: false,
source: 'web',
ip: '203.0.113.24',
user_agent: 'Firefox 131 on macOS',
params: {},
changes: [],
...extra,
}
}
const activityTarget = (type: string, id: string, name: string, exists = true) => ({
type,
id,
name,
exists,
})
const activityBoardEvents = [
activityEvent(
1,
'2026-10-06T09:42:18Z',
'article.publish',
'articles',
{ type: 'user', id: 3, name: 'Marta Alves' },
{
target: activityTarget('article', '8', 'Spring release'),
changes: [{ field: 'online', kind: 'text', before: 'false', after: 'true' }],
},
),
activityEvent(
2,
'2026-10-06T09:15:04Z',
'article.edit',
'articles',
{ type: 'user', id: 3, name: 'Marta Alves' },
{
target: activityTarget('article', '5', 'Why we reuse original floorboards'),
changes: [
{
field: 'title',
kind: 'text',
before: 'Why we reuse floorboards',
after: 'Why we reuse original floorboards',
},
{ field: 'body', kind: 'body', delta: 312 },
],
},
),
activityEvent(
3,
'2026-10-06T08:31:47Z',
'auth.login_failed',
'auth',
{ type: 'anonymous', id: null, name: 'alex@example.com' },
{
outcome: 'failed',
security: true,
failure_reason: 'The password did not match. 3 attempts in 10 minutes.',
ip: '198.51.100.77',
user_agent: 'Chrome 129 on Windows',
},
),
activityEvent(
4,
'2026-10-06T07:50:09Z',
'api_key.revoke',
'api_keys',
{ type: 'user', id: 2, name: 'Jonas Weber' },
{
target: activityTarget('api_key', '71', 'Mobile app'),
security: true,
ip: '203.0.113.90',
user_agent: 'Safari 18 on iOS',
},
),
activityEvent(
5,
'2026-10-06T02:00:02Z',
'system.media_purge',
'system',
{ type: 'system', id: null, name: 'System' },
{
source: 'system',
ip: null,
user_agent: null,
params: { merged: 3, articles_updated: 5 },
},
),
activityEvent(
6,
'2026-10-05T17:20:36Z',
'invite.accept',
'users',
{ type: 'user', id: 4, name: 'Inès Duarte' },
{ target: activityTarget('invite', '2', 'jonas@renew.example'), security: true },
),
activityEvent(
7,
'2026-10-05T15:05:51Z',
'media.upload',
'media',
{ type: 'api_key', id: 7, name: 'Website sync' },
{
target: activityTarget('image', '4', 'north-wall.jpg'),
source: 'api_key',
ip: '192.0.2.18',
user_agent: 'node-fetch 3.3',
},
),
activityEvent(
8,
'2026-10-05T13:40:13Z',
'media.delete',
'media',
{ type: 'user', id: 3, name: 'Marta Alves' },
{ target: activityTarget('image', '9', 'old-banner.png', false) },
),
activityEvent(
9,
'2026-10-05T10:12:27Z',
'article.publish',
'articles',
{ type: 'user', id: 4, name: 'Inès Duarte' },
{
target: activityTarget('article', '6', activityLongTitle),
outcome: 'failed',
failure_reason: 'The article has no cover image. Add one, then publish again.',
},
),
activityEvent(
10,
'2026-10-05T02:00:01Z',
'system.activity_cleanup',
'system',
{ type: 'system', id: null, name: 'System' },
{ source: 'system', ip: null, user_agent: null, params: { removed: 142 } },
),
activityEvent(
11,
'2026-10-03T15:48:22Z',
'article.edit',
'articles',
{ type: 'user', id: 4, name: 'Inès Duarte' },
{
target: activityTarget('article', '2', 'Choosing lime plaster for old stone walls'),
changes: [
{
field: 'title',
kind: 'text',
before: 'Renovation notes, project write-ups and practical guides from the Renew studio team in Lisbon, covering timber, lime plaster and stone restoration for older homes.',
after: 'Renovation notes, project write-ups and practical guides from the Renew studio team in Lisbon, covering timber, lime plaster and stone restoration for older homes, plus a seasonal maintenance checklist.',
},
],
},
),
activityEvent(
12,
'2026-10-03T14:02:40Z',
'api_key.create',
'api_keys',
{ type: 'user', id: 2, name: 'Jonas Weber' },
{
target: activityTarget('api_key', '71', 'Mobile app', false),
security: true,
ip: '203.0.113.90',
},
),
activityEvent(
13,
'2026-10-03T13:20:05Z',
'user.update_profile',
'users',
{ type: 'user', id: 4, name: 'Inès Duarte' },
{
target: activityTarget('user', '8', 'Sam Rivera'),
changes: [
{
field: 'display_name',
kind: 'text',
before: 'Sam Rivera',
after: 'Samuel Rivera',
},
],
},
),
activityEvent(
14,
'2026-10-03T10:36:58Z',
'media.upload',
'media',
{ type: 'user', id: 3, name: 'Marta Alves' },
{ target: activityTarget('image', '5', 'floorboards-copy.jpg') },
),
activityEvent(
15,
'2026-10-03T08:05:12Z',
'auth.password_reset_completed',
'auth',
{ type: 'user', id: 4, name: 'Inès Duarte' },
{ target: activityTarget('user', '8', 'Sam Rivera'), security: true },
),
]
function activityPage(items: unknown[], total: number) {
return { items, total, page: 1, size: 25 }
}
function activityApi(
overrides: Record<string, ApiMock> = {},
session: ApiMock = activityOwnerSession,
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': session,
'GET /v1/activity': { status: 200, body: activityPage(activityBoardEvents, 1284) },
'GET /v1/activity/summary': { status: 200, body: activitySummaryDefault },
...overrides,
}
}
const activityClockSensitive: VisualState = { path: '/activity', skip: 'clock-sensitive' }
async function filterActivityToNothing(page: Page) {
const mobile = (page.viewportSize()?.width ?? 0) < 768
const search = page.getByPlaceholder('Search activity')
await search.fill('sitemap')
await search.press('Enter')
if (mobile) {
await page.getByRole('button', { name: /^Filters/ }).click()
}
await page.getByRole('button', { name: 'Category', exact: true }).click()
await page.getByRole('option', { name: 'Media', exact: true }).click()
await page
.getByRole('group', { name: 'Outcome' })
.getByRole('button', { name: 'Failed', exact: true })
.click()
if (mobile) {
await page.getByRole('button', { name: /^Show \d+ results?$/ }).click()
}
await page.getByText('No activity matches these filters').waitFor()
await page.mouse.move(0, 0)
}
const activityStates: Record<string, VisualState> = {
'Default, large dataset': activityClockSensitive,
Loading: {
path: '/activity',
api: activityApi({
'GET /v1/activity': { status: 200, body: activityPage([], 0), hang: true },
'GET /v1/activity/summary': { status: 200, body: activitySummaryDefault, hang: true },
}),
steps: async (page) => {
await page.waitForTimeout(400)
},
},
'Zero-value summary strip': activityClockSensitive,
'Filtered, card applied': activityClockSensitive,
'Mobile filters sheet': activityClockSensitive,
'Empty, no activity yet': {
path: '/activity',
api: activityApi({
'GET /v1/activity': { status: 200, body: activityPage([], 0) },
'GET /v1/activity/summary': { status: 200, body: activitySummaryNone },
}),
},
'Empty, filters match nothing': {
path: '/activity',
api: activityApi({
'GET /v1/activity': {
status: 200,
bodyFor: (url) =>
url.searchParams.has('q') ||
url.searchParams.has('category') ||
url.searchParams.has('outcome')
? activityPage([], 0)
: activityPage(activityBoardEvents, 1284),
},
}),
steps: filterActivityToNothing,
},
'Error with retry': {
path: '/activity',
api: activityApi({ 'GET /v1/activity': { status: 500 } }),
},
'Forbidden (403)': {
path: '/activity',
api: activityApi({ 'GET /v1/activity': { status: 403 } }, activityEditorSession),
},
'Edit with a diff': activityClockSensitive,
'Settings diff, long text': activityClockSensitive,
'Failed action, very long target title': activityClockSensitive,
'Deleted target, no diff': activityClockSensitive,
'System-initiated, no human actor': activityClockSensitive,
'Security event, failed login': activityClockSensitive,
Default: activityClockSensitive,
Empty: {
path: '/activity',
api: activityApi(
{ 'GET /v1/activity': { status: 200, body: activityPage([], 0) } },
activityEditorSession,
),
},
Details: activityClockSensitive,
}
export const visualStates: Record<string, Record<string, VisualState>> = { export const visualStates: Record<string, Record<string, VisualState>> = {
activity: activityStates,
profile: { profile: {
'Account menu': { 'Account menu': {
path: '/home', path: '/home',
now: boardNow, now: boardNow,
api: { api: {
'GET /v1/auth/me': verifiedSession, 'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock,
'GET /v1/article/': { status: 200, body: sixArticlesFixture }, 'GET /v1/article/': { status: 200, body: sixArticlesFixture },
'GET /v1/system/update': { status: 200, body: updateStatusBody() }, 'GET /v1/system/update': { status: 200, body: updateStatusBody() },
}, },
@@ -2725,13 +3121,34 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
'Up to date': { path: '/settings', skip: 'clock-sensitive' }, 'Up to date': { path: '/settings', skip: 'clock-sensitive' },
Checking: { Checking: {
path: '/settings', path: '/settings',
exception: {
maxPercent: 11.3,
reason: newCardAbsentReason,
approvedBy: 'fallen',
},
api: updateApi('owner', updateNeverChecked(), updateCheckHangs), api: updateApi('owner', updateNeverChecked(), updateCheckHangs),
}, },
'Check failed': { path: '/settings', skip: 'clock-sensitive' }, 'Check failed': { path: '/settings', skip: 'clock-sensitive' },
'Update available': { path: '/settings', skip: 'clock-sensitive' }, 'Update available': { path: '/settings', skip: 'clock-sensitive' },
'Update available, notes open': { path: '/settings', skip: 'clock-sensitive' }, 'Update available, notes open': { path: '/settings', skip: 'clock-sensitive' },
Downloading: { path: '/settings', api: updateApi('owner', updateDownloading()) }, Downloading: {
Verifying: { path: '/settings', api: updateApi('owner', updateJob('verifying')) }, path: '/settings',
api: updateApi('owner', updateDownloading()),
exception: {
maxPercent: 10.6,
reason: newCardAbsentReason,
approvedBy: 'fallen',
},
},
Verifying: {
path: '/settings',
api: updateApi('owner', updateJob('verifying')),
exception: {
maxPercent: 10.8,
reason: newCardAbsentReason,
approvedBy: 'fallen',
},
},
'Ready to install': { path: '/settings', api: updateApi('owner', updateJob('ready')) }, 'Ready to install': { path: '/settings', api: updateApi('owner', updateJob('ready')) },
'Restart: confirm': { 'Restart: confirm': {
path: '/settings', path: '/settings',
@@ -2771,14 +3188,29 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
}, },
'Download failed': { 'Download failed': {
path: '/settings', path: '/settings',
exception: {
maxPercent: 10.9,
reason: newCardAbsentReason,
approvedBy: 'fallen',
},
api: updateApi('owner', updateJob('failed', { error: 'download' })), api: updateApi('owner', updateJob('failed', { error: 'download' })),
}, },
'Verification failed': { 'Verification failed': {
path: '/settings', path: '/settings',
exception: {
maxPercent: 10.5,
reason: newCardAbsentReason,
approvedBy: 'fallen',
},
api: updateApi('owner', updateJob('failed', { error: 'verification' })), api: updateApi('owner', updateJob('failed', { error: 'verification' })),
}, },
'Too many requests (429)': { 'Too many requests (429)': {
path: '/settings', path: '/settings',
exception: {
maxPercent: 11.3,
reason: newCardAbsentReason,
approvedBy: 'fallen',
},
api: updateApi('owner', updateNeverChecked(), { api: updateApi('owner', updateNeverChecked(), {
status: 429, status: 429,
headers: { 'Retry-After': '42' }, headers: { 'Retry-After': '42' },
@@ -2786,10 +3218,20 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
}, },
'Not prepared': { 'Not prepared': {
path: '/settings', path: '/settings',
exception: {
maxPercent: 11.9,
reason: newCardAbsentReason,
approvedBy: 'fallen',
},
api: updateApi('owner', updateAvailable()), api: updateApi('owner', updateAvailable()),
}, },
'Not prepared: command copied': { 'Not prepared: command copied': {
path: '/settings', path: '/settings',
exception: {
maxPercent: 11.9,
reason: newCardAbsentReason,
approvedBy: 'fallen',
},
origin: integrationOrigin, origin: integrationOrigin,
api: updateApi('owner', updateAvailable()), api: updateApi('owner', updateAvailable()),
steps: async (page) => { steps: async (page) => {
@@ -3114,52 +3556,88 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
home: { home: {
Default: { Default: {
path: '/home', path: '/home',
exception: {
maxPercent: 13.5,
reason: newCardAbsentReason,
approvedBy: 'fallen',
},
now: boardNow, now: boardNow,
api: { api: {
'GET /v1/auth/me': verifiedSession, 'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock,
'GET /v1/system/update': { status: 200, body: updateStatusBody() }, 'GET /v1/system/update': { status: 200, body: updateStatusBody() },
'GET /v1/article/': { status: 200, body: sixArticlesFixture }, 'GET /v1/article/': { status: 200, body: sixArticlesFixture },
}, },
}, },
Loading: { Loading: {
path: '/home', path: '/home',
exception: {
maxPercent: 18.6,
reason: newCardAbsentReason,
approvedBy: 'fallen',
},
now: boardNow, now: boardNow,
api: { api: {
'GET /v1/auth/me': verifiedSession, 'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock,
'GET /v1/system/update': { status: 200, body: updateStatusBody() }, 'GET /v1/system/update': { status: 200, body: updateStatusBody() },
'GET /v1/article/': { status: 200, body: sixArticlesFixture, hang: true }, 'GET /v1/article/': { status: 200, body: sixArticlesFixture, hang: true },
}, },
}, },
'Empty, first run': { 'Empty, first run': {
path: '/home', path: '/home',
exception: {
maxPercent: 11.4,
reason: newCardAbsentReason,
approvedBy: 'fallen',
},
now: boardNow, now: boardNow,
api: { api: {
'GET /v1/auth/me': verifiedSession, 'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock,
'GET /v1/system/update': { status: 200, body: updateStatusBody() }, 'GET /v1/system/update': { status: 200, body: updateStatusBody() },
'GET /v1/article/': { status: 200, body: [] }, 'GET /v1/article/': { status: 200, body: [] },
}, },
}, },
Error: { Error: {
path: '/home', path: '/home',
exception: {
maxPercent: 25.3,
reason: newCardAbsentReason,
approvedBy: 'fallen',
},
now: boardNow, now: boardNow,
api: { api: {
'GET /v1/auth/me': verifiedSession, 'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock,
'GET /v1/system/update': { status: 200, body: updateStatusBody() }, 'GET /v1/system/update': { status: 200, body: updateStatusBody() },
'GET /v1/article/': { status: 500 }, 'GET /v1/article/': { status: 500 },
}, },
}, },
'Update available (owner)': { 'Update available (owner)': {
path: '/home', path: '/home',
exception: {
maxPercent: 11.9,
reason: newCardAbsentReason,
approvedBy: 'fallen',
},
api: { api: {
'GET /v1/auth/me': verifiedSession, 'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock,
'GET /v1/article/': { status: 200, body: sixArticlesFixture }, 'GET /v1/article/': { status: 200, body: sixArticlesFixture },
'GET /v1/system/update': { status: 200, body: updateAvailable() }, 'GET /v1/system/update': { status: 200, body: updateAvailable() },
}, },
}, },
'Update ready to install (owner)': { 'Update ready to install (owner)': {
path: '/home', path: '/home',
exception: {
maxPercent: 11.9,
reason: newCardAbsentReason,
approvedBy: 'fallen',
},
api: { api: {
'GET /v1/auth/me': verifiedSession, 'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock,
'GET /v1/article/': { status: 200, body: sixArticlesFixture }, 'GET /v1/article/': { status: 200, body: sixArticlesFixture },
'GET /v1/system/update': { status: 200, body: updateJob('ready') }, 'GET /v1/system/update': { status: 200, body: updateJob('ready') },
}, },