Skip to main content

autumn_admin_plugin/
lib.rs

1//! # autumn-admin-plugin
2//!
3//! Out-of-the-box admin panel plugin for autumn-web applications.
4//!
5//! Provides auto-generated CRUD views, search, filtering, and audit trails
6//! for any model registered via the [`AdminPlugin`] builder. The UI is
7//! server-rendered with Maud + HTMX โ€” no JS build step required.
8//!
9//! # Quick start
10//!
11//! ```rust,ignore
12//! use autumn_admin_plugin::{prelude::*, AdminPlugin};
13//!
14//! autumn_web::app()
15//!     .plugin(
16//!         AdminPlugin::new()
17//!             .register(ProjectAdmin::default())
18//!             .register(TicketAdmin::default()),
19//!     )
20//!     .routes(routes![...])
21//!     .run()
22//!     .await;
23//! ```
24//!
25//! # Security
26//!
27//! The plugin requires the `"admin"` role in the session by default. Override
28//! with [`AdminPlugin::require_role`] (pass `None` to disable; not recommended
29//! for production).
30//!
31//! # Naming convention
32//!
33//! First-party plugin: `autumn-<name>-plugin`.
34
35mod auth;
36mod registry;
37mod routes;
38mod templates;
39mod traits;
40
41pub use registry::AdminRegistry;
42pub use traits::{
43    AdminAction, AdminError, AdminField, AdminFieldKind, AdminFuture, AdminModel, ListParams,
44    ListResult, SortDirection,
45};
46
47/// Common downstream imports for implementing admin models.
48pub mod prelude {
49    pub use crate::{
50        AdminError, AdminField, AdminFieldKind, AdminFuture, AdminModel, ListParams, ListResult,
51        SortDirection,
52    };
53}
54
55use std::borrow::Cow;
56use std::sync::Arc;
57
58use autumn_web::app::AppBuilder;
59use autumn_web::plugin::Plugin;
60use autumn_web::route_listing::RouteInfo;
61
62/// The admin panel plugin.
63///
64/// Register models via `.register()` and the plugin will mount a full admin
65/// UI under the configured prefix (default: `/admin`).
66pub struct AdminPlugin {
67    registry: AdminRegistry,
68    prefix: String,
69    actuator_prefix: String,
70    auth_session_key: String,
71    require_role: Option<String>,
72}
73
74impl AdminPlugin {
75    /// Create a new admin plugin with default settings.
76    ///
77    /// Mounts at `/admin` and requires the `"admin"` role in the session.
78    /// Links to the actuator UI under `/actuator`. Reads the user
79    /// identifier from session key `"user_id"` (Autumn's default
80    /// `auth.session_key`).
81    #[must_use]
82    pub fn new() -> Self {
83        Self {
84            registry: AdminRegistry::new(),
85            prefix: "/admin".to_owned(),
86            actuator_prefix: "/actuator".to_owned(),
87            auth_session_key: "user_id".to_owned(),
88            require_role: Some("admin".to_owned()),
89        }
90    }
91
92    /// Override the URL prefix (default: `/admin`).
93    #[must_use]
94    pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
95        self.prefix = prefix.into();
96        self
97    }
98
99    /// Override the actuator mount prefix that dashboard links/polling target
100    /// (default: `/actuator`). Must match `config.actuator.prefix` from your
101    /// autumn config โ€” the plugin cannot read it automatically because config
102    /// is loaded after `Plugin::build` runs.
103    #[must_use]
104    pub fn actuator_prefix(mut self, prefix: impl Into<String>) -> Self {
105        self.actuator_prefix = prefix.into();
106        self
107    }
108
109    /// Override the session key the role middleware reads to detect an
110    /// authenticated user. Default: `"user_id"`, matching Autumn's default
111    /// `auth.session_key`. Must match whatever your application populates
112    /// after login โ€” e.g. set this to `"uid"` if you configured
113    /// `auth.session_key = "uid"`.
114    ///
115    /// The plugin can't read `config.auth.session_key` automatically
116    /// because config is loaded after `Plugin::build` runs.
117    #[must_use]
118    pub fn auth_session_key(mut self, key: impl Into<String>) -> Self {
119        self.auth_session_key = key.into();
120        self
121    }
122
123    /// Set the required session role for accessing the admin panel.
124    ///
125    /// Pass `None` to disable role checks entirely. Authentication
126    /// (a populated `user_id` session key) is always required when a role
127    /// is set.
128    #[must_use]
129    pub fn require_role(mut self, role: impl Into<Option<String>>) -> Self {
130        self.require_role = role.into();
131        self
132    }
133
134    /// Register a model for admin management.
135    ///
136    /// The model must implement [`AdminModel`], which provides field metadata,
137    /// CRUD operations, and display configuration.
138    #[must_use]
139    pub fn register<M: AdminModel>(mut self, model: M) -> Self {
140        self.registry.register(model);
141        self
142    }
143}
144
145impl Default for AdminPlugin {
146    fn default() -> Self {
147        Self::new()
148    }
149}
150
151impl Plugin for AdminPlugin {
152    fn name(&self) -> Cow<'static, str> {
153        Cow::Borrowed("autumn-admin-plugin")
154    }
155
156    fn build(self, app: AppBuilder) -> AppBuilder {
157        let Self {
158            registry,
159            prefix,
160            actuator_prefix,
161            auth_session_key,
162            require_role,
163        } = self;
164        let registry = Arc::new(registry);
165        let router = routes::admin_router(
166            Arc::clone(&registry),
167            &prefix,
168            actuator_prefix.clone(),
169            auth_session_key.clone(),
170            require_role.clone(),
171        );
172
173        tracing::info!(
174            prefix = %prefix,
175            actuator_prefix = %actuator_prefix,
176            auth_session_key = %auth_session_key,
177            models = registry.model_count(),
178            role = require_role.as_deref().unwrap_or("<none>"),
179            "๐Ÿ‚ Autumn Admin mounted"
180        );
181
182        // Declare routes for `autumn routes` listing. The underlying Axum router
183        // is added via nest() which is opaque to route enumeration, so we
184        // explicitly register route metadata here.
185        let declared = admin_route_infos(&prefix);
186
187        app.nest(&prefix, router).declare_plugin_routes(declared)
188    }
189}
190
191/// Generate the route metadata list for this plugin's mounted routes.
192///
193/// Kept in sync with `routes::admin_router` โ€” update here when routes are
194/// added or removed from the admin router.
195pub(crate) fn admin_route_infos(prefix: &str) -> Vec<RouteInfo> {
196    [
197        ("GET", prefix.to_string()),
198        ("GET", format!("{prefix}/jobs")),
199        ("GET", format!("{prefix}/jobs/counters")),
200        ("POST", format!("{prefix}/jobs/{{id}}/retry")),
201        ("POST", format!("{prefix}/jobs/{{id}}/discard")),
202        ("POST", format!("{prefix}/jobs/{{id}}/cancel")),
203        ("GET", format!("{prefix}/{{slug}}")),
204        ("POST", format!("{prefix}/{{slug}}")),
205        ("GET", format!("{prefix}/{{slug}}/new")),
206        ("GET", format!("{prefix}/{{slug}}/{{id}}")),
207        ("POST", format!("{prefix}/{{slug}}/{{id}}")),
208        ("DELETE", format!("{prefix}/{{slug}}/{{id}}")),
209        ("GET", format!("{prefix}/{{slug}}/{{id}}/edit")),
210        ("POST", format!("{prefix}/{{slug}}/actions")),
211        ("GET", format!("{prefix}{}", &*routes::ADMIN_JS_PATH)),
212    ]
213    .into_iter()
214    .map(|(method, path)| RouteInfo {
215        method: method.to_owned(),
216        path,
217        handler: format!("admin::{}", method.to_lowercase()),
218        source: autumn_web::route_listing::RouteSource::User, // overwritten by declare_plugin_routes
219        middleware: vec![],
220    })
221    .collect()
222}
223
224// โ”€โ”€ Conformance reference tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
225//
226// These tests are the reference example for the Autumn plugin conformance
227// workflow documented in docs/plugins.md.  They use
228// `autumn_web::plugin_conformance` to verify that the admin plugin's declared
229// routes satisfy all conformance checks before publication.
230//
231// See docs/plugins.md ยง "Plugin conformance and publishing checklist" for the
232// equivalent `autumn plugin-check` CLI invocation.
233
234#[cfg(test)]
235mod conformance_tests {
236    use autumn_web::plugin_conformance::{ConformanceConfig, run_conformance};
237    use autumn_web::route_listing::{RouteInfo, RouteSource};
238
239    const PLUGIN_NAME: &str = "autumn-admin-plugin";
240
241    /// Build the routes that `AdminPlugin` contributes under `prefix`,
242    /// attributed to the plugin. Reuses `admin_route_infos` from the outer
243    /// module and overrides the source to `Plugin(PLUGIN_NAME)`.
244    fn admin_routes(prefix: &str) -> Vec<RouteInfo> {
245        super::admin_route_infos(prefix)
246            .into_iter()
247            .map(|mut r| {
248                r.source = RouteSource::Plugin(PLUGIN_NAME.to_owned());
249                r
250            })
251            .collect()
252    }
253
254    #[test]
255    fn admin_plugin_routes_are_attributed_to_plugin_name() {
256        let routes = admin_routes("/admin");
257        let result = autumn_web::plugin_conformance::check_route_attribution(PLUGIN_NAME, &routes);
258        assert_eq!(
259            result.status,
260            autumn_web::plugin_conformance::CheckStatus::Pass,
261            "route attribution failed: {}",
262            result.message
263        );
264    }
265
266    #[test]
267    fn admin_plugin_routes_live_under_admin_prefix() {
268        let routes = admin_routes("/admin");
269        let result =
270            autumn_web::plugin_conformance::check_route_prefix(PLUGIN_NAME, "/admin", &[], &routes);
271        assert_eq!(
272            result.status,
273            autumn_web::plugin_conformance::CheckStatus::Pass,
274            "prefix check failed: {}\n{:?}",
275            result.message,
276            result.diagnostics
277        );
278    }
279
280    #[test]
281    fn admin_plugin_declares_builtin_job_routes() {
282        let routes = admin_routes("/admin");
283        let declared: std::collections::HashSet<(&str, &str)> = routes
284            .iter()
285            .map(|route| (route.method.as_str(), route.path.as_str()))
286            .collect();
287
288        for (method, path) in [
289            ("GET", "/admin/jobs"),
290            ("GET", "/admin/jobs/counters"),
291            ("POST", "/admin/jobs/{id}/retry"),
292            ("POST", "/admin/jobs/{id}/discard"),
293            ("POST", "/admin/jobs/{id}/cancel"),
294        ] {
295            assert!(
296                declared.contains(&(method, path)),
297                "missing declared admin job route {method} {path}"
298            );
299        }
300    }
301
302    #[test]
303    fn admin_plugin_has_no_route_collisions_in_isolation() {
304        let routes = admin_routes("/admin");
305        let (result, _) = autumn_web::plugin_conformance::check_collisions(&routes);
306        assert_eq!(
307            result.status,
308            autumn_web::plugin_conformance::CheckStatus::Pass,
309            "unexpected collision: {}\n{:?}",
310            result.message,
311            result.diagnostics
312        );
313    }
314
315    #[test]
316    fn admin_plugin_sensitive_surfaces_declared_with_role_requirement() {
317        let routes = admin_routes("/admin");
318        let declared = vec![autumn_web::plugin_conformance::SensitiveRoute {
319            path_pattern: "/admin".to_owned(),
320            auth_mechanism: "Role: admin required via AdminPlugin::require_role \
321                             (default) or AdminPlugin::require_role(None) to disable"
322                .to_owned(),
323        }];
324        let result = autumn_web::plugin_conformance::check_sensitive_surfaces(
325            PLUGIN_NAME,
326            &routes,
327            &declared,
328        );
329        assert_eq!(
330            result.status,
331            autumn_web::plugin_conformance::CheckStatus::Pass,
332            "sensitive-surfaces check failed: {}",
333            result.message
334        );
335    }
336
337    #[test]
338    fn admin_plugin_sensitive_surfaces_fail_without_declaration() {
339        let routes = admin_routes("/admin");
340        let result =
341            autumn_web::plugin_conformance::check_sensitive_surfaces(PLUGIN_NAME, &routes, &[]);
342        assert_eq!(
343            result.status,
344            autumn_web::plugin_conformance::CheckStatus::Fail,
345            "expected FAIL when sensitive routes are undeclared"
346        );
347    }
348
349    #[test]
350    fn admin_plugin_passes_full_conformance_with_config() {
351        let routes = admin_routes("/admin");
352        let config = ConformanceConfig::new(PLUGIN_NAME)
353            .prefix("/admin")
354            .sensitive_route(
355                "/admin",
356                "Role: admin required via AdminPlugin::require_role",
357            );
358        let report = run_conformance(&config, &routes);
359        assert!(
360            report.passed(),
361            "AdminPlugin conformance failed:\n{}",
362            report.to_text_report()
363        );
364    }
365
366    #[test]
367    fn admin_plugin_collision_with_host_route_detected() {
368        let mut routes = admin_routes("/admin");
369        // Simulate a host app that accidentally defines GET /admin
370        routes.push(RouteInfo {
371            method: "GET".to_owned(),
372            path: "/admin".to_owned(),
373            handler: "host::admin_redirect".to_owned(),
374            source: RouteSource::User,
375            middleware: vec![],
376        });
377        let (result, diagnostics) = autumn_web::plugin_conformance::check_collisions(&routes);
378        assert_eq!(
379            result.status,
380            autumn_web::plugin_conformance::CheckStatus::Fail,
381            "expected collision to be detected"
382        );
383        let diag = &diagnostics[0];
384        assert_eq!(diag.method, "GET");
385        assert_eq!(diag.path, "/admin");
386        let sources: Vec<&str> = diag
387            .contributors
388            .iter()
389            .map(|c| c.source.as_str())
390            .collect();
391        assert!(
392            sources.contains(&"user"),
393            "missing user contributor: {sources:?}"
394        );
395        assert!(
396            sources.contains(&"plugin:autumn-admin-plugin"),
397            "missing plugin contributor: {sources:?}"
398        );
399    }
400
401    #[test]
402    fn admin_plugin_custom_prefix_passes_conformance() {
403        let routes = admin_routes("/backend");
404        let config = ConformanceConfig::new(PLUGIN_NAME)
405            .prefix("/backend")
406            .sensitive_route(
407                "/backend",
408                "Role: admin required via AdminPlugin::require_role",
409            );
410        let report = run_conformance(&config, &routes);
411        assert!(
412            report.passed(),
413            "AdminPlugin with custom prefix failed conformance:\n{}",
414            report.to_text_report()
415        );
416    }
417
418    #[test]
419    fn admin_plugin_double_registration_detected() {
420        // Simulate registering the admin plugin twice โ€” its routes appear twice.
421        let mut routes = admin_routes("/admin");
422        routes.extend(admin_routes("/admin"));
423        let result =
424            autumn_web::plugin_conformance::check_duplicate_registration(PLUGIN_NAME, &routes);
425        assert_eq!(
426            result.status,
427            autumn_web::plugin_conformance::CheckStatus::Fail,
428            "expected duplicate-registration FAIL when plugin installed twice"
429        );
430    }
431
432    #[test]
433    fn admin_plugin_single_registration_passes_duplicate_check() {
434        let routes = admin_routes("/admin");
435        let result =
436            autumn_web::plugin_conformance::check_duplicate_registration(PLUGIN_NAME, &routes);
437        assert_eq!(
438            result.status,
439            autumn_web::plugin_conformance::CheckStatus::Pass,
440            "single registration should pass: {}",
441            result.message
442        );
443    }
444}