From 1b4c109c2cd460f2b1c9e3fd9f0081e0e3e4b47a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Leite?= Date: Mon, 20 Jul 2026 19:04:13 -0300 Subject: [PATCH] feat: user-activity admin table with filters + pagination (#1021 fatia 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final slice of #1021 — the UI. A new admin-only page "Atividades dos usuários" (`/admin/activity`) lists the user_activity rows captured in fatia 2, alongside the administrative audit log. - Page: columns When · User · Event · App · IP; anonymous access shows as "Anonymous"; hashed-colour avatars like the Users/Audit pages. - Sub-nav: two pills (user activity ⇄ administrative audit) on both pages, reusing the existing .admin-nav-link.is-active style (no new CSS); both keep nav_section="audit" so the top nav stays highlighted. The top-nav link is unchanged (the new view is one click away). - Filters the store already supports exactly (no LIKE machinery): event select, user + app dropdowns of distinct values (new distinct_usernames / distinct_specs), and a period select (24h/7d/30d -> since). - Server-side pagination (50/page, #999 pattern) with filters preserved on the pager links (percent-encoded). - i18n across pt/en/es/fr incl. a Fluent plural in the pager status. - Declares urlencoding = "2" directly (already transitive; 2.1.3 in the lock) for the pager query-string encoding. Tests: UI integration (admin-only gate, rendered rows, event/user filters, 60-row pagination counted by the per-row data-avatar marker) and the store distinct helpers (SQLite + postgres-it). Swapped ti-login-2 (outside the shipped Tabler subset) for ti-login to keep the icon_subset gate green. Closes #1021 (External-click capture noted as a follow-up). Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 1 + crates/ruscker-admin/Cargo.toml | 3 + .../ruscker-admin/assets/i18n/en/landing.ftl | 30 +++ .../ruscker-admin/assets/i18n/es/landing.ftl | 30 +++ .../ruscker-admin/assets/i18n/fr/landing.ftl | 30 +++ .../ruscker-admin/assets/i18n/pt/landing.ftl | 30 +++ crates/ruscker-admin/src/db/user_activity.rs | 57 ++++ crates/ruscker-admin/src/routes/admin.rs | 2 + .../src/routes/admin/user_activity.rs | 251 ++++++++++++++++++ .../templates/admin/activity.html | 166 ++++++++++++ .../ruscker-admin/templates/admin/audit.html | 14 +- crates/ruscker-admin/tests/activity_ui.rs | 181 +++++++++++++ 12 files changed, 794 insertions(+), 1 deletion(-) create mode 100644 crates/ruscker-admin/src/routes/admin/user_activity.rs create mode 100644 crates/ruscker-admin/templates/admin/activity.html create mode 100644 crates/ruscker-admin/tests/activity_ui.rs diff --git a/Cargo.lock b/Cargo.lock index 0ace5697..4e9f997c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3054,6 +3054,7 @@ dependencies = [ "tracing", "unic-langid", "url", + "urlencoding", "uuid", "zeroize", ] diff --git a/crates/ruscker-admin/Cargo.toml b/crates/ruscker-admin/Cargo.toml index 6c1ce672..dfd633c0 100644 --- a/crates/ruscker-admin/Cargo.toml +++ b/crates/ruscker-admin/Cargo.toml @@ -45,6 +45,9 @@ hyper-rustls = "0.27" rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } # Cron parsing for scheduled jobs (#986) — verified on crates.io (3.0.1). croner = "3" +# Percent-encode filter values onto pager query strings (#1021). Already +# in the tree transitively; named directly so the usage is declared. +urlencoding = "2" futures-util = { workspace = true } async-stream = { workspace = true } lol_html = { workspace = true } diff --git a/crates/ruscker-admin/assets/i18n/en/landing.ftl b/crates/ruscker-admin/assets/i18n/en/landing.ftl index 0937a3d5..466cc72c 100644 --- a/crates/ruscker-admin/assets/i18n/en/landing.ftl +++ b/crates/ruscker-admin/assets/i18n/en/landing.ftl @@ -1042,3 +1042,33 @@ admin-users-mfa-reset-confirm = Reset this user's 2FA? The key and ALL recovery admin-users-mfa-reset = Reset 2FA admin-users-mfa-not-configured = 2FA not configured admin-users-mfa-reset-ok = 2FA and its recovery codes were reset. + +# User activity (#1021) +admin-activity-title = User Activity +admin-activity-subtitle = Logins and app accesses, newest first. +admin-activity-tab-users = User activity +admin-activity-tab-admin = Administrative activity +admin-activity-event = Event +admin-activity-event-all = All events +admin-activity-event-login = Login +admin-activity-event-access = App access +admin-activity-user = User +admin-activity-user-all = All users +admin-activity-app = App +admin-activity-app-all = All apps +admin-activity-period = Period +admin-activity-period-all = Any time +admin-activity-period-24h = Last 24 hours +admin-activity-period-7d = Last 7 days +admin-activity-period-30d = Last 30 days +admin-activity-col-when = When +admin-activity-col-user = User +admin-activity-col-event = Event +admin-activity-col-app = App +admin-activity-col-ip = IP +admin-activity-empty = No activity yet — or the filter matches nothing. +admin-activity-anonymous = Anonymous +admin-activity-pager-status = Page { $page } of { $pages } · { $total } { $total -> + [one] record + *[other] records + } diff --git a/crates/ruscker-admin/assets/i18n/es/landing.ftl b/crates/ruscker-admin/assets/i18n/es/landing.ftl index ea9121b9..003feb3c 100644 --- a/crates/ruscker-admin/assets/i18n/es/landing.ftl +++ b/crates/ruscker-admin/assets/i18n/es/landing.ftl @@ -1042,3 +1042,33 @@ admin-users-mfa-reset-confirm = ¿Restablecer el 2FA de este usuario? La clave y admin-users-mfa-reset = Restablecer 2FA admin-users-mfa-not-configured = 2FA no configurado admin-users-mfa-reset-ok = Se restablecieron el 2FA y sus códigos de recuperación. + +# User activity (#1021) +admin-activity-title = Actividad de Usuarios +admin-activity-subtitle = Inicios de sesión y accesos a aplicaciones, del más reciente al más antiguo. +admin-activity-tab-users = Actividad de usuarios +admin-activity-tab-admin = Actividad administrativa +admin-activity-event = Evento +admin-activity-event-all = Todos los eventos +admin-activity-event-login = Inicio de sesión +admin-activity-event-access = Acceso a app +admin-activity-user = Usuario +admin-activity-user-all = Todos los usuarios +admin-activity-app = Aplicación +admin-activity-app-all = Todas las aplicaciones +admin-activity-period = Período +admin-activity-period-all = Cualquier período +admin-activity-period-24h = Últimas 24 horas +admin-activity-period-7d = Últimos 7 días +admin-activity-period-30d = Últimos 30 días +admin-activity-col-when = Cuándo +admin-activity-col-user = Usuario +admin-activity-col-event = Evento +admin-activity-col-app = Aplicación +admin-activity-col-ip = IP +admin-activity-empty = Sin actividad todavía — o el filtro no coincide con nada. +admin-activity-anonymous = Anónimo +admin-activity-pager-status = Página { $page } de { $pages } · { $total } { $total -> + [one] registro + *[other] registros + } diff --git a/crates/ruscker-admin/assets/i18n/fr/landing.ftl b/crates/ruscker-admin/assets/i18n/fr/landing.ftl index b5c40e60..5f96bd2d 100644 --- a/crates/ruscker-admin/assets/i18n/fr/landing.ftl +++ b/crates/ruscker-admin/assets/i18n/fr/landing.ftl @@ -1042,3 +1042,33 @@ admin-users-mfa-reset-confirm = Réinitialiser la 2FA de cet utilisateur ? La cl admin-users-mfa-reset = Réinitialiser la 2FA admin-users-mfa-not-configured = 2FA non configurée admin-users-mfa-reset-ok = La 2FA et ses codes de récupération ont été réinitialisés. + +# User activity (#1021) +admin-activity-title = Activité des Utilisateurs +admin-activity-subtitle = Connexions et accès aux applications, du plus récent au plus ancien. +admin-activity-tab-users = Activité des utilisateurs +admin-activity-tab-admin = Activité administrative +admin-activity-event = Événement +admin-activity-event-all = Tous les événements +admin-activity-event-login = Connexion +admin-activity-event-access = Accès à une app +admin-activity-user = Utilisateur +admin-activity-user-all = Tous les utilisateurs +admin-activity-app = Application +admin-activity-app-all = Toutes les applications +admin-activity-period = Période +admin-activity-period-all = Toute période +admin-activity-period-24h = Dernières 24 heures +admin-activity-period-7d = 7 derniers jours +admin-activity-period-30d = 30 derniers jours +admin-activity-col-when = Quand +admin-activity-col-user = Utilisateur +admin-activity-col-event = Événement +admin-activity-col-app = Application +admin-activity-col-ip = IP +admin-activity-empty = Aucune activité pour l'instant — ou le filtre ne correspond à rien. +admin-activity-anonymous = Anonyme +admin-activity-pager-status = Page { $page } sur { $pages } · { $total } { $total -> + [one] enregistrement + *[other] enregistrements + } diff --git a/crates/ruscker-admin/assets/i18n/pt/landing.ftl b/crates/ruscker-admin/assets/i18n/pt/landing.ftl index b7de315c..dd2d0e14 100644 --- a/crates/ruscker-admin/assets/i18n/pt/landing.ftl +++ b/crates/ruscker-admin/assets/i18n/pt/landing.ftl @@ -1046,3 +1046,33 @@ admin-users-mfa-reset-confirm = Redefinir o 2FA deste usuário? A chave e TODOS admin-users-mfa-reset = Redefinir 2FA admin-users-mfa-not-configured = 2FA não configurado admin-users-mfa-reset-ok = O 2FA e os códigos de recuperação foram redefinidos. + +# User activity (#1021) +admin-activity-title = Atividades dos Usuários +admin-activity-subtitle = Logins e acessos a aplicações, do mais recente para o mais antigo. +admin-activity-tab-users = Atividades dos usuários +admin-activity-tab-admin = Atividades administrativas +admin-activity-event = Evento +admin-activity-event-all = Todos os eventos +admin-activity-event-login = Login +admin-activity-event-access = Acesso a app +admin-activity-user = Usuário +admin-activity-user-all = Todos os usuários +admin-activity-app = Aplicação +admin-activity-app-all = Todas as aplicações +admin-activity-period = Período +admin-activity-period-all = Qualquer período +admin-activity-period-24h = Últimas 24 horas +admin-activity-period-7d = Últimos 7 dias +admin-activity-period-30d = Últimos 30 dias +admin-activity-col-when = Quando +admin-activity-col-user = Usuário +admin-activity-col-event = Evento +admin-activity-col-app = Aplicação +admin-activity-col-ip = IP +admin-activity-empty = Nenhuma atividade ainda — ou o filtro não bate em nada. +admin-activity-anonymous = Anônimo +admin-activity-pager-status = Página { $page } de { $pages } · { $total } { $total -> + [one] registro + *[other] registros + } diff --git a/crates/ruscker-admin/src/db/user_activity.rs b/crates/ruscker-admin/src/db/user_activity.rs index b0f96390..b874a2c1 100644 --- a/crates/ruscker-admin/src/db/user_activity.rs +++ b/crates/ruscker-admin/src/db/user_activity.rs @@ -232,6 +232,32 @@ pub async fn count_filtered(db: &ConfigDb, filter: &ActivityFilter) -> Result Result> { + let sql = + "SELECT DISTINCT username FROM user_activity WHERE username IS NOT NULL ORDER BY username ASC"; + let rows: Vec<(String,)> = match db { + ConfigDb::Sqlite(pool) => sqlx::query_as(sql).fetch_all(pool).await, + ConfigDb::Postgres(pool) => sqlx::query_as(sql).fetch_all(pool).await, + } + .context("distinct activity usernames")?; + Ok(rows.into_iter().map(|(u,)| u).collect()) +} + +/// Distinct non-null spec ids ever recorded — populates the app filter +/// dropdown. +pub async fn distinct_specs(db: &ConfigDb) -> Result> { + let sql = + "SELECT DISTINCT spec_id FROM user_activity WHERE spec_id IS NOT NULL ORDER BY spec_id ASC"; + let rows: Vec<(String,)> = match db { + ConfigDb::Sqlite(pool) => sqlx::query_as(sql).fetch_all(pool).await, + ConfigDb::Postgres(pool) => sqlx::query_as(sql).fetch_all(pool).await, + } + .context("distinct activity specs")?; + Ok(rows.into_iter().map(|(s,)| s).collect()) +} + #[cfg(test)] mod tests { use super::*; @@ -340,6 +366,30 @@ mod tests { assert!(page1.last().unwrap().id > page3.first().unwrap().id); } + #[tokio::test] + async fn distinct_dropdowns_exclude_nulls_and_sort() { + let db = ConfigDb::Sqlite(open_memory().await.unwrap()); + insert_batch( + &db, + &[ + access(Some("bob"), "ops", "a1"), + access(Some("alice"), "sales", "a2"), + access(None, "public", "a3"), // anonymous → no username + login("alice", "l1"), // login → no spec + ], + ) + .await + .unwrap(); + assert_eq!( + distinct_usernames(&db).await.unwrap(), + vec!["alice".to_string(), "bob".to_string()] + ); + assert_eq!( + distinct_specs(&db).await.unwrap(), + vec!["ops".to_string(), "public".to_string(), "sales".to_string()] + ); + } + #[tokio::test] async fn since_until_filter_bounds_the_time_window() { let db = ConfigDb::Sqlite(open_memory().await.unwrap()); @@ -437,5 +487,12 @@ mod tests { }; assert_eq!(count_filtered(&db, &by_user).await.unwrap(), 2); assert_eq!(all.iter().filter(|r| r.username.is_none()).count(), 1); + + // Distinct dropdowns exclude NULLs and are ordered. + assert_eq!(distinct_usernames(&db).await.unwrap(), vec!["alice".to_string()]); + assert_eq!( + distinct_specs(&db).await.unwrap(), + vec!["public".to_string(), "sales".to_string()] + ); } } diff --git a/crates/ruscker-admin/src/routes/admin.rs b/crates/ruscker-admin/src/routes/admin.rs index b0019654..9ec3443a 100644 --- a/crates/ruscker-admin/src/routes/admin.rs +++ b/crates/ruscker-admin/src/routes/admin.rs @@ -24,6 +24,7 @@ use crate::AppState; pub mod audit; pub mod blocks; +pub mod user_activity; pub mod credentials; pub mod dashboard; pub mod disk; @@ -64,6 +65,7 @@ pub fn routes() -> Router { .merge(landing::routes()) .merge(blocks::routes()) .merge(audit::routes()) + .merge(user_activity::routes()) .merge(groups::routes()) .merge(logs::routes()) .merge(schedules_ui::routes()) diff --git a/crates/ruscker-admin/src/routes/admin/user_activity.rs b/crates/ruscker-admin/src/routes/admin/user_activity.rs new file mode 100644 index 00000000..e0b071b0 --- /dev/null +++ b/crates/ruscker-admin/src/routes/admin/user_activity.rs @@ -0,0 +1,251 @@ +//! Admin > Atividades dos usuários (#1021). +//! +//! Read-only listing of the `user_activity` table (logins + interactive +//! app accesses, with identity), with filters for event kind, user, app, +//! and time window, and server-side pagination. Sibling to the +//! administrative audit log (`/admin/audit`); a two-pill sub-nav switches +//! between them under the "Atividades" section. + +use askama::Template; +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::get, + Router, +}; +use chrono::{Duration, Utc}; +use serde::Deserialize; + +use crate::auth::{RequireAdmin, Role}; +use crate::db::{self, user_activity::ActivityFilter, user_activity::ActivityRow}; +use crate::i18n::{Locale, Locales}; +use crate::theme::Theme; +use crate::AppState; + +pub fn routes() -> Router { + Router::new().route("/admin/activity", get(index)) +} + +/// Rows per page — server-side paginated like the users table (#999) so a +/// long history doesn't fetch/render thousands of rows. +const ACTIVITY_PER_PAGE: i64 = 50; + +#[derive(Debug, Deserialize, Default)] +pub struct ActivityQuery { + /// "" (all) | "login.success" | "app.access". + #[serde(default)] + pub event: Option, + /// Exact username (from the dropdown of distinct users). + #[serde(default)] + pub user: Option, + /// Exact spec id (from the dropdown of distinct apps). + #[serde(default)] + pub app: Option, + /// "" (all) | "24h" | "7d" | "30d". + #[serde(default)] + pub period: Option, + /// 1-based page; out-of-range clamps. + #[serde(default)] + pub page: Option, +} + +impl ActivityQuery { + /// The event filter normalized to a recognized value or `""` (all) — an + /// unknown/forged value is treated as "all", never passed to the query. + fn event_norm(&self) -> String { + match self.event.as_deref() { + Some("login.success") => "login.success".into(), + Some("app.access") => "app.access".into(), + _ => String::new(), + } + } + fn user_norm(&self) -> String { + self.user.as_deref().unwrap_or("").trim().to_string() + } + fn app_norm(&self) -> String { + self.app.as_deref().unwrap_or("").trim().to_string() + } + /// Period normalized to a recognized preset or `""` (all). + fn period_norm(&self) -> String { + match self.period.as_deref() { + Some("24h") => "24h".into(), + Some("7d") => "7d".into(), + Some("30d") => "30d".into(), + _ => String::new(), + } + } +} + +#[derive(Template)] +#[template(path = "admin/activity.html")] +struct ActivityPage<'a> { + locale: Locale, + theme: Theme, + locales: &'a Locales, + locales_all: &'static [Locale], + /// Mount prefix for base-path-correct URLs (#294). + base: std::sync::Arc, + /// "audit" so the top-nav "Atividades" stays highlighted for both the + /// users and the administrative views. + nav_section: &'static str, + role: Role, + rows: Vec, + /// Distinct values for the user / app dropdowns. + users: Vec, + apps: Vec, + /// Echoed filter state (also carried on the pager links). + event: String, + user: String, + app: String, + period: String, + /// Server-side pagination state (1-based page, total pages, match count). + page: i64, + pages: i64, + total: i64, +} + +impl ActivityPage<'_> { + fn t(&self, key: &str) -> String { + self.locales.t(self.locale, key, None) + } + + /// Localized "Página X de Y · N registros" line under the table. + fn pager_status(&self) -> String { + use fluent_bundle::FluentArgs; + let mut args = FluentArgs::new(); + args.set("page", self.page); + args.set("pages", self.pages); + args.set("total", self.total); + self.locales + .t(self.locale, "admin-activity-pager-status", Some(&args)) + } + + /// Human label + tone/icon for an event type. + fn event_label(&self, event_type: &str) -> String { + match event_type { + "login.success" => self.t("admin-activity-event-login"), + "app.access" => self.t("admin-activity-event-access"), + other => other.to_string(), + } + } + fn event_icon(&self, event_type: &str) -> &'static str { + match event_type { + "login.success" => "ti-login", + "app.access" => "ti-app-window", + _ => "ti-history", + } + } + fn event_tone(&self, event_type: &str) -> &'static str { + match event_type { + "login.success" => "tone-update", + "app.access" => "tone-create", + _ => "tone-other", + } + } + + /// The active filters as a URL query suffix (`&event=…&user=…`), for the + /// pager links. Values are percent-encoded; empty filters are omitted. + fn query_suffix(&self) -> String { + let mut s = String::new(); + for (k, v) in [ + ("event", &self.event), + ("user", &self.user), + ("app", &self.app), + ("period", &self.period), + ] { + if !v.is_empty() { + s.push('&'); + s.push_str(k); + s.push('='); + s.push_str(&urlencoding::encode(v)); + } + } + s + } + + /// Whether any filter is active (drives the "clear" link). + fn filtered(&self) -> bool { + !self.event.is_empty() + || !self.user.is_empty() + || !self.app.is_empty() + || !self.period.is_empty() + } +} + +async fn index( + _: RequireAdmin, + State(state): State, + loc: Locale, + theme: Theme, + Query(q): Query, +) -> Response { + let Some(db) = state.db.as_ref() else { + return (StatusCode::SERVICE_UNAVAILABLE, "no db").into_response(); + }; + + let event = q.event_norm(); + let user = q.user_norm(); + let app = q.app_norm(); + let period = q.period_norm(); + let since = match period.as_str() { + "24h" => Some(Utc::now() - Duration::hours(24)), + "7d" => Some(Utc::now() - Duration::days(7)), + "30d" => Some(Utc::now() - Duration::days(30)), + _ => None, + }; + + let mut filter = ActivityFilter { + event_type: (!event.is_empty()).then(|| event.clone()), + username: (!user.is_empty()).then(|| user.clone()), + spec_id: (!app.is_empty()).then(|| app.clone()), + since, + until: None, + limit: ACTIVITY_PER_PAGE, + offset: 0, + }; + + let total = match db::user_activity::count_filtered(db, &filter).await { + Ok(n) => n, + Err(e) => { + tracing::error!(error = ?e, "count user_activity failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response(); + } + }; + // Ceiling division (i64::div_ceil is still unstable on our floor). + let pages = ((total + ACTIVITY_PER_PAGE - 1) / ACTIVITY_PER_PAGE).max(1); + let page = q.page.unwrap_or(1).clamp(1, pages); + filter.offset = (page - 1) * ACTIVITY_PER_PAGE; + + let rows = match db::user_activity::list_page(db, &filter).await { + Ok(r) => r, + Err(e) => { + tracing::error!(error = ?e, "list user_activity failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response(); + } + }; + + let users = db::user_activity::distinct_usernames(db).await.unwrap_or_default(); + let apps = db::user_activity::distinct_specs(db).await.unwrap_or_default(); + + let page = ActivityPage { + locale: loc, + theme, + locales: &state.locales, + locales_all: &Locale::ALL, + base: state.base_path.clone(), + nav_section: "audit", + role: Role::Admin, + rows, + users, + apps, + event, + user, + app, + period, + page, + pages, + total, + }; + super::render(&page) +} diff --git a/crates/ruscker-admin/templates/admin/activity.html b/crates/ruscker-admin/templates/admin/activity.html new file mode 100644 index 00000000..cbcfb059 --- /dev/null +++ b/crates/ruscker-admin/templates/admin/activity.html @@ -0,0 +1,166 @@ +{% extends "admin/_layout.html" %} + +{% block title %}{{ self.t("admin-activity-title") }} · Ruscker{% endblock %} + +{% block content %} + +
+
+

{{ self.t("admin-activity-title") }}

+

{{ self.t("admin-activity-subtitle") }}

+
+
+ +{# ── Sub-nav: users activity ⇄ administrative audit ──────────── #} + + +{# ── Filter bar ───────────────────────────────────────────── #} +
+
+ + +
+ + {% if !users.is_empty() %} +
+ + +
+ {% endif %} + + {% if !apps.is_empty() %} +
+ + +
+ {% endif %} + +
+ + +
+ + + + {% if self.filtered() %} + + {{ self.t("filter-clear") }} + + {% endif %} +
+ +{# ── Rows ─────────────────────────────────────────────────── #} +{% if rows.is_empty() %} +
+ {{ self.t("admin-activity-empty") }} +
+{% else %} + + + + + + + + + + + + {% for r in rows %} + + + + + + + + {% endfor %} + +
{{ self.t("admin-activity-col-when") }}{{ self.t("admin-activity-col-user") }}{{ self.t("admin-activity-col-event") }}{{ self.t("admin-activity-col-app") }}{{ self.t("admin-activity-col-ip") }}
{{ r.occurred_at.format("%d/%m/%Y %H:%M:%S") }} + + + {% match r.username %}{% when Some with (u) %}{{ u }}{% when None %}{{ self.t("admin-activity-anonymous") }}{% endmatch %} + + + + + {{ self.event_label(r.event_type.as_str()) }} + + {% match r.spec_id %}{% when Some with (s) %}{{ s }}{% when None %}{% endmatch %}{% match r.client_ip %}{% when Some with (ip) %}{{ ip }}{% when None %}{% endmatch %}
+ + {# ── Pager — links preserve the active filters. #} +
+ {% if page > 1 %} + + {{ self.t("admin-users-prev") }} + + {% endif %} + {{ self.pager_status() }} + {% if page < pages %} + + {{ self.t("admin-users-next") }} + + {% endif %} +
+{% endif %} + + + + +{% endblock %} diff --git a/crates/ruscker-admin/templates/admin/audit.html b/crates/ruscker-admin/templates/admin/audit.html index 06824c59..8f44b80a 100644 --- a/crates/ruscker-admin/templates/admin/audit.html +++ b/crates/ruscker-admin/templates/admin/audit.html @@ -6,7 +6,7 @@
-
+

{{ self.t("admin-audit-title") }}

{{ self.t("admin-audit-subtitle") }}

@@ -14,6 +14,18 @@

{{ self.t("admin-audit-title") }}
{{ entries.len() }}

+ {# ── Sub-nav: users activity ⇄ administrative audit (#1021) ──── #} + + {# ── Filter bar ───────────────────────────────────────────── #}
diff --git a/crates/ruscker-admin/tests/activity_ui.rs b/crates/ruscker-admin/tests/activity_ui.rs new file mode 100644 index 00000000..ce78c581 --- /dev/null +++ b/crates/ruscker-admin/tests/activity_ui.rs @@ -0,0 +1,181 @@ +//! Integration tests for the "Atividades dos usuários" admin page +//! (#1021 fatia 3): admin-only gate, rendered rows, filters (event/user), +//! and server-side pagination. Harness mirrors `schedules_ui.rs` — a full +//! `router()` over a temp-file SQLite with a session minted directly. + +use axum::body::Body; +use axum::http::{header, Request, StatusCode}; +use ruscker_admin::activity::{ActivityEvent, AuthMethod}; +use ruscker_admin::auth::{AdminAuth, Role, COOKIE_NAME}; +use ruscker_admin::db::{user_activity, ConfigDb}; +use ruscker_admin::i18n::Locales; +use ruscker_admin::{router, AppState}; +use ruscker_config::Config; +use std::sync::Arc; +use tower::ServiceExt; + +const CONFIG: &str = "proxy:\n title: Test\n specs: []\n"; + +async fn open_db() -> ConfigDb { + use std::sync::atomic::{AtomicU64, Ordering}; + static N: AtomicU64 = AtomicU64::new(0); + let path = std::env::temp_dir().join(format!( + "ruscker-activity-ui-{}-{}.db", + std::process::id(), + N.fetch_add(1, Ordering::Relaxed) + )); + let _ = std::fs::remove_file(&path); + ConfigDb::Sqlite(ruscker_admin::db::open(&path).await.unwrap()) +} + +async fn app_state(db: ConfigDb) -> AppState { + std::env::set_var("DOCKER_REGISTRY_PASSWORD", "test"); + let config = Config::from_yaml(CONFIG).expect("parse config"); + let locales = Locales::load().expect("load locales"); + AppState { + config: Arc::new(config), + base_path: Arc::from(""), + locales: Arc::new(locales), + admin_auth: AdminAuth::with_token("test-token"), + admin_sessions: Arc::new(ruscker_admin::auth::InMemoryAdminSessionStore::default()), + log_buffer: None, + login_limiter: Arc::new(ruscker_admin::auth::LoginRateLimiter::default_policy()), + api_limiter: Arc::new(ruscker_admin::ratelimit::ApiRateLimiter::new()), + db: Some(db), + images_dir: None, + master_key: Default::default(), + backend: None, + replicas: Arc::new(tokio::sync::RwLock::new(Default::default())), + cookie_key: ruscker_proxy::sticky::CookieKey::random(), + spawn_locks: Arc::new(dashmap::DashMap::new()), + sessions: Arc::new(ruscker_admin::sessions::InMemorySessionStore::new()), + logout_index: Arc::new(dashmap::DashMap::new()), + leader: Arc::new(ruscker_admin::leader::AlwaysLeader), + metrics: ruscker_admin::metrics_cache::MetricsCache::new(), + draining: Arc::new(std::sync::atomic::AtomicBool::new(false)), + spec_cache: std::sync::Arc::new(dashmap::DashMap::new()), + identity_cache: Default::default(), + catalog_cache: std::sync::Arc::new(tokio::sync::RwLock::new(None)), + access_counter: Arc::new(ruscker_admin::access_counter::AccessCounter::default()), + alerts: ruscker_admin::alerts::AlertSink::default(), + activity: ruscker_admin::activity::ActivitySink::default(), + } +} + +async fn admin_cookie(state: &AppState) -> String { + let sid = state + .admin_sessions + .create(Role::Admin, Some("root".into())) + .await; + format!("{COOKIE_NAME}={sid}") +} + +async fn get(state: AppState, path: &str, cookie: Option<&str>) -> (StatusCode, String) { + let app = router(state); + let mut req = Request::builder().method("GET").uri(path); + if let Some(c) = cookie { + req = req.header(header::COOKIE, c); + } + let resp = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); + let status = resp.status(); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + (status, String::from_utf8_lossy(&bytes).into_owned()) +} + +/// One `data-avatar="…"` attribute is rendered per table row (the avatar +/// script references the bare `[data-avatar]` selector, without `="`, so it +/// isn't counted), giving the number of rows actually on the page. +fn row_count(body: &str) -> usize { + body.matches("data-avatar=\"").count() +} + +#[tokio::test] +async fn requires_admin() { + let db = open_db().await; + let state = app_state(db).await; + // No session → the guard bounces (never 200). + let (status, _) = get(state, "/admin/activity", None).await; + assert_ne!(status, StatusCode::OK); +} + +#[tokio::test] +async fn renders_rows_and_event_filter_narrows() { + let db = open_db().await; + user_activity::insert_batch( + &db, + &[ + ActivityEvent::login_success("alice", AuthMethod::Password, "l1", None), + ActivityEvent::app_access(Some("alice".into()), "sales-dash", "a1", None, None), + ActivityEvent::app_access(None, "public-dash", "a2", None, None), + ], + ) + .await + .unwrap(); + let state = app_state(db).await; + let cookie = admin_cookie(&state).await; + + // Unfiltered: all three rows, and the app names + user appear. + let (status, body) = get(state.clone(), "/admin/activity", Some(&cookie)).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(row_count(&body), 3); + assert!(body.contains("sales-dash")); + assert!(body.contains("alice")); + + // event=login.success → only the login row (no app spec in the table). + let (status, body) = get( + state.clone(), + "/admin/activity?event=login.success", + Some(&cookie), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(row_count(&body), 1); + + // event=app.access → the two accesses. + let (_, body) = get(state, "/admin/activity?event=app.access", Some(&cookie)).await; + assert_eq!(row_count(&body), 2); +} + +#[tokio::test] +async fn user_filter_narrows_to_one_user() { + let db = open_db().await; + user_activity::insert_batch( + &db, + &[ + ActivityEvent::app_access(Some("alice".into()), "s", "a1", None, None), + ActivityEvent::app_access(Some("bob".into()), "s", "a2", None, None), + ActivityEvent::login_success("alice", AuthMethod::Password, "l1", None), + ], + ) + .await + .unwrap(); + let state = app_state(db).await; + let cookie = admin_cookie(&state).await; + + // alice: her app access + her login = 2 rows. + let (status, body) = get(state, "/admin/activity?user=alice", Some(&cookie)).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(row_count(&body), 2); +} + +#[tokio::test] +async fn paginates_server_side() { + let db = open_db().await; + // 60 accesses → 50 on page 1, 10 on page 2 (newest first). + let events: Vec<_> = (0..60) + .map(|i| ActivityEvent::app_access(Some("u".into()), "s", format!("k{i}"), None, None)) + .collect(); + user_activity::insert_batch(&db, &events).await.unwrap(); + let state = app_state(db).await; + let cookie = admin_cookie(&state).await; + + let (status, body1) = get(state.clone(), "/admin/activity", Some(&cookie)).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(row_count(&body1), 50, "first page caps at the page size"); + + let (status, body2) = get(state, "/admin/activity?page=2", Some(&cookie)).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(row_count(&body2), 10, "second page has the remainder"); +}