Skip to main content

reinhardt_admin/core/
site.rs

1//! Admin site management
2//!
3//! The `AdminSite` is the central registry for all admin models and provides
4//! routing, authentication, and rendering functionality.
5
6use crate::core::ModelAdmin;
7use crate::core::model_admin::AdminUser;
8use crate::server::admin_auth::{AdminLoginAuthenticator, AdminUserLoader};
9use crate::types::{AdminError, AdminResult};
10use async_trait::async_trait;
11use dashmap::DashMap;
12use parking_lot::RwLock;
13use reinhardt_core::macros::injectable;
14use reinhardt_di::{DiResult, FactoryOutput, Injectable, InjectionContext};
15use std::sync::Arc;
16
17/// The main admin site that manages all registered models
18///
19/// # Examples
20///
21/// ```
22/// use reinhardt_admin::core::AdminSite;
23///
24/// let admin = AdminSite::new("My Application");
25/// assert_eq!(admin.name(), "My Application");
26/// ```
27#[injectable(scope = Singleton, prebuilt = true)]
28#[derive(Clone)]
29pub struct AdminSite {
30	/// Site name displayed in the admin interface
31	name: String,
32
33	/// URL prefix for admin routes (default: "/admin")
34	url_prefix: String,
35
36	/// Registry of model admins indexed by model name
37	registry: Arc<DashMap<String, Arc<dyn ModelAdmin>>>,
38
39	/// Site-level configuration
40	config: Arc<RwLock<AdminSiteConfig>>,
41
42	/// Favicon data (PNG, ICO, etc.)
43	favicon_data: Arc<RwLock<Option<Vec<u8>>>>,
44
45	/// Type-erased user loader for admin authentication.
46	///
47	/// When `None`, [`AdminDefaultUser`] is used as a fallback.
48	///
49	/// [`AdminDefaultUser`]: crate::server::user::AdminDefaultUser
50	user_loader: Option<Arc<AdminUserLoader>>,
51
52	/// Type-erased login authenticator for admin login.
53	///
54	/// When `None`, [`AdminDefaultUser`] is used as a fallback.
55	///
56	/// [`AdminDefaultUser`]: crate::server::user::AdminDefaultUser
57	login_authenticator: Option<Arc<AdminLoginAuthenticator>>,
58
59	/// JWT secret for token generation during admin login.
60	///
61	/// When `None`, admin login is disabled.
62	jwt_secret: Option<Vec<u8>>,
63}
64
65/// Provider key for the admin site dependency.
66#[reinhardt_di::injectable_key]
67pub struct AdminSiteKey;
68
69/// Configuration for the admin site
70#[derive(Debug, Clone)]
71pub struct AdminSiteConfig {
72	/// Site title shown in browser tab
73	pub site_title: String,
74
75	/// Header text shown at the top of admin pages
76	pub site_header: String,
77
78	/// Index page title
79	pub index_title: String,
80
81	/// Items per page in list views
82	pub list_per_page: usize,
83
84	/// Enable search functionality
85	pub enable_search: bool,
86
87	/// Enable filtering functionality
88	pub enable_filters: bool,
89}
90
91impl Default for AdminSiteConfig {
92	fn default() -> Self {
93		Self {
94			site_title: "Admin Panel".into(),
95			site_header: "Administration".into(),
96			index_title: "Dashboard".into(),
97			list_per_page: 100,
98			enable_search: true,
99			enable_filters: true,
100		}
101	}
102}
103
104impl AdminSite {
105	/// Create a new admin site
106	///
107	/// # Examples
108	///
109	/// ```
110	/// use reinhardt_admin::core::AdminSite;
111	///
112	/// let admin = AdminSite::new("E-commerce Admin");
113	/// ```
114	pub fn new(name: impl Into<String>) -> Self {
115		Self {
116			name: name.into(),
117			url_prefix: "/admin".into(),
118			registry: Arc::new(DashMap::new()),
119			config: Arc::new(RwLock::new(AdminSiteConfig::default())),
120			favicon_data: Arc::new(RwLock::new(None)),
121			user_loader: None,
122			login_authenticator: None,
123			jwt_secret: None,
124		}
125	}
126
127	/// Get the site name
128	///
129	/// # Examples
130	///
131	/// ```
132	/// use reinhardt_admin::core::AdminSite;
133	///
134	/// let admin = AdminSite::new("My Admin");
135	/// assert_eq!(admin.name(), "My Admin");
136	/// ```
137	pub fn name(&self) -> &str {
138		&self.name
139	}
140
141	/// Set the URL prefix for admin routes
142	///
143	/// # Examples
144	///
145	/// ```
146	/// use reinhardt_admin::core::AdminSite;
147	///
148	/// let mut admin = AdminSite::new("Admin");
149	/// admin.set_url_prefix("/manage");
150	/// assert_eq!(admin.url_prefix(), "/manage");
151	/// ```
152	pub fn set_url_prefix(&mut self, prefix: impl Into<String>) {
153		self.url_prefix = prefix.into();
154	}
155
156	/// Get the URL prefix
157	pub fn url_prefix(&self) -> &str {
158		&self.url_prefix
159	}
160
161	/// Set favicon data from bytes
162	///
163	/// # Examples
164	///
165	/// ```
166	/// use reinhardt_admin::core::AdminSite;
167	///
168	/// let admin = AdminSite::new("Admin");
169	/// admin.set_favicon(vec![0x89, 0x50, 0x4E, 0x47]); // PNG magic bytes
170	/// assert!(admin.favicon_data().is_some());
171	/// ```
172	pub fn set_favicon(&self, data: Vec<u8>) {
173		*self.favicon_data.write() = Some(data);
174	}
175
176	/// Get favicon data (cloned)
177	///
178	/// Returns None if no favicon has been configured.
179	pub fn favicon_data(&self) -> Option<Vec<u8>> {
180		self.favicon_data.read().clone()
181	}
182
183	/// Configure the admin site
184	///
185	/// # Examples
186	///
187	/// ```
188	/// use reinhardt_admin::core::AdminSite;
189	/// use reinhardt_admin::core::site::AdminSiteConfig;
190	///
191	/// let admin = AdminSite::new("Admin");
192	/// admin.configure(|config| {
193	///     config.site_title = "My Custom Admin".into();
194	///     config.list_per_page = 50;
195	/// });
196	/// ```
197	pub fn configure<F>(&self, f: F)
198	where
199		F: FnOnce(&mut AdminSiteConfig),
200	{
201		let mut config = self.config.write();
202		f(&mut config);
203	}
204
205	/// Get the current configuration
206	pub fn config(&self) -> AdminSiteConfig {
207		self.config.read().clone()
208	}
209
210	/// Set the user type for admin authentication.
211	///
212	/// This determines which database table and model is used to load the
213	/// authenticated user in admin server functions. The type `U` must
214	/// implement `BaseUser`, `AdminUser`, and the ORM trait (`Model`).
215	/// Types annotated with `#[model(...)]` and `#[user(full = true)]`
216	/// satisfy this automatically via the blanket `impl<T: FullUser> AdminUser for T`.
217	/// Simpler user models that only implement `BaseUser` can manually
218	/// implement `AdminUser` to use admin authentication.
219	///
220	/// If this method is not called, [`AdminDefaultUser`] (table `auth_user`)
221	/// is used as the default.
222	///
223	/// # Examples
224	///
225	/// ```rust,ignore
226	/// use reinhardt_admin::core::AdminSite;
227	///
228	/// let mut site = AdminSite::new("My Admin");
229	/// site.set_user_type::<MyCustomUser>();
230	/// ```
231	///
232	/// [`AdminDefaultUser`]: crate::server::user::AdminDefaultUser
233	pub fn set_user_type<U>(&mut self) -> &mut Self
234	where
235		U: reinhardt_auth::BaseUser
236			+ AdminUser
237			+ reinhardt_db::orm::Model
238			+ Clone
239			+ Send
240			+ Sync
241			+ 'static,
242		<U as reinhardt_auth::BaseUser>::PrimaryKey: std::str::FromStr + ToString + Send + Sync,
243		<<U as reinhardt_auth::BaseUser>::PrimaryKey as std::str::FromStr>::Err: std::fmt::Debug,
244		<U as reinhardt_db::orm::Model>::PrimaryKey:
245			From<<U as reinhardt_auth::BaseUser>::PrimaryKey>,
246	{
247		self.user_loader = Some(Arc::new(
248			crate::server::admin_auth::create_admin_user_loader::<U>(),
249		));
250		self.login_authenticator = Some(Arc::new(
251			crate::server::admin_auth::create_admin_login_authenticator::<U>(),
252		));
253		self
254	}
255
256	/// Returns the registered user loader, if any.
257	pub(crate) fn user_loader(&self) -> Option<Arc<AdminUserLoader>> {
258		self.user_loader.clone()
259	}
260
261	/// Returns the registered login authenticator, if any.
262	pub(crate) fn login_authenticator(&self) -> Option<Arc<AdminLoginAuthenticator>> {
263		self.login_authenticator.clone()
264	}
265
266	/// Sets the JWT secret used for token generation during admin login.
267	///
268	/// The secret should be at least 32 bytes for adequate security.
269	/// Without this, admin login functionality will be disabled.
270	///
271	/// # Example
272	///
273	/// ```ignore
274	/// use reinhardt_admin::core::AdminSite;
275	///
276	/// let mut site = AdminSite::new("Admin");
277	/// site.set_jwt_secret(b"my-very-secret-key-at-least-32-bytes!");
278	/// ```
279	pub fn set_jwt_secret(&mut self, secret: &[u8]) -> &mut Self {
280		self.jwt_secret = Some(secret.to_vec());
281		self
282	}
283
284	/// Returns the JWT secret, if configured.
285	pub(crate) fn jwt_secret(&self) -> Option<&[u8]> {
286		self.jwt_secret.as_deref()
287	}
288
289	/// Register a model with the admin site
290	///
291	/// # Examples
292	///
293	/// ```ignore
294	/// use reinhardt_admin::core::{AdminSite, ModelAdminConfig};
295	///
296	/// let admin = AdminSite::new("Admin");
297	///
298	/// let user_admin = ModelAdminConfig::builder()
299	///     .model_name("User")
300	///     .list_display(vec!["id", "username", "email"])
301	///     .build()?;
302	///
303	/// admin.register("User", user_admin);
304	/// ```
305	pub fn register(
306		&self,
307		model_name: impl Into<String>,
308		admin: impl ModelAdmin + 'static,
309	) -> AdminResult<()> {
310		let model_name = model_name.into();
311		// Reject case-insensitive duplicates (URLs are lowercased, so "User" and "user"
312		// would collide at /admin/user/).
313		let needle = model_name.to_lowercase();
314		if let Some(existing) = self
315			.registry
316			.iter()
317			.find(|e| e.key().to_lowercase() == needle)
318		{
319			return Err(AdminError::ValidationError(format!(
320				"Model '{}' is already registered (as '{}')",
321				model_name,
322				existing.key()
323			)));
324		}
325		self.registry.insert(model_name, Arc::new(admin));
326		Ok(())
327	}
328
329	/// Unregister a model from the admin site
330	///
331	/// # Examples
332	///
333	/// ```no_run
334	/// use reinhardt_admin::core::AdminSite;
335	///
336	/// let admin = AdminSite::new("Admin");
337	/// // ... register User ...
338	/// admin.unregister("User");
339	/// ```
340	pub fn unregister(&self, model_name: &str) -> AdminResult<()> {
341		let needle = model_name.to_lowercase();
342		let key = self
343			.registry
344			.iter()
345			.find(|entry| entry.key().to_lowercase() == needle)
346			.map(|entry| entry.key().clone())
347			.ok_or_else(|| AdminError::ModelNotRegistered(model_name.into()))?;
348		self.registry.remove(&key);
349		Ok(())
350	}
351
352	/// Check if a model is registered
353	///
354	/// # Examples
355	///
356	/// ```
357	/// use reinhardt_admin::core::AdminSite;
358	///
359	/// let admin = AdminSite::new("Admin");
360	/// assert!(!admin.is_registered("User"));
361	/// ```
362	pub fn is_registered(&self, model_name: &str) -> bool {
363		let needle = model_name.to_lowercase();
364		self.registry
365			.iter()
366			.any(|entry| entry.key().to_lowercase() == needle)
367	}
368
369	/// Get the admin for a specific model
370	///
371	/// # Examples
372	///
373	/// ```no_run
374	/// use reinhardt_admin::core::AdminSite;
375	///
376	/// let admin = AdminSite::new("Admin");
377	/// // ... register User ...
378	/// let user_admin = admin.get_model_admin("User").unwrap();
379	/// ```
380	pub fn get_model_admin(&self, model_name: &str) -> AdminResult<Arc<dyn ModelAdmin>> {
381		let needle = model_name.to_lowercase();
382		self.registry
383			.iter()
384			.find(|entry| entry.key().to_lowercase() == needle)
385			.map(|entry| Arc::clone(entry.value()))
386			.ok_or_else(|| AdminError::ModelNotRegistered(model_name.into()))
387	}
388
389	/// Get all registered model names
390	///
391	/// # Examples
392	///
393	/// ```
394	/// use reinhardt_admin::core::AdminSite;
395	///
396	/// let admin = AdminSite::new("Admin");
397	/// assert_eq!(admin.registered_models().len(), 0);
398	/// ```
399	pub fn registered_models(&self) -> Vec<String> {
400		self.registry
401			.iter()
402			.map(|entry| entry.key().clone())
403			.collect()
404	}
405
406	/// Get the number of registered models
407	///
408	/// # Examples
409	///
410	/// ```
411	/// use reinhardt_admin::core::AdminSite;
412	///
413	/// let admin = AdminSite::new("Admin");
414	/// assert_eq!(admin.model_count(), 0);
415	/// ```
416	pub fn model_count(&self) -> usize {
417		self.registry.len()
418	}
419
420	/// Clear all registered models
421	///
422	/// # Examples
423	///
424	/// ```
425	/// use reinhardt_admin::core::AdminSite;
426	///
427	/// let admin = AdminSite::new("Admin");
428	/// admin.clear();
429	/// assert_eq!(admin.model_count(), 0);
430	/// ```
431	pub fn clear(&self) {
432		self.registry.clear();
433	}
434}
435
436/// Injectable trait implementation for AdminSite
437///
438/// Resolves `AdminSite` directly from the singleton scope.
439/// The `AdminSite` must be registered via `admin_routes_with_di()` which
440/// returns a `DiRegistrationList` to be attached to the router.
441#[async_trait]
442impl Injectable for AdminSite {
443	async fn inject(ctx: &InjectionContext) -> DiResult<Self> {
444		ctx.get_singleton::<Self>()
445			.map(|arc| (*arc).clone())
446			.ok_or_else(|| reinhardt_di::DiError::NotRegistered {
447				type_name: "AdminSite".into(),
448				hint: "AdminSite must be registered as a singleton. \
449				       Use admin_routes_with_di(site) and attach the returned \
450				       DiRegistrationList via .with_di_registrations() on UnifiedRouter."
451					.into(),
452			})
453	}
454}
455
456#[reinhardt_di::injectable(scope = "singleton")]
457async fn admin_site_provider(#[inject] site: AdminSite) -> FactoryOutput<AdminSiteKey, AdminSite> {
458	FactoryOutput::new(site)
459}
460
461#[cfg(all(test, server))]
462mod tests {
463	use super::*;
464	use crate::core::ModelAdminConfig;
465	use reinhardt_di::SingletonScope;
466	use rstest::rstest;
467
468	#[rstest]
469	fn test_admin_site_creation() {
470		let admin = AdminSite::new("Test Admin");
471		assert_eq!(admin.name(), "Test Admin");
472		assert_eq!(admin.url_prefix(), "/admin");
473		assert_eq!(admin.model_count(), 0);
474	}
475
476	#[rstest]
477	#[tokio::test]
478	async fn test_admin_site_resolves_through_keyed_provider() {
479		let singleton = Arc::new(SingletonScope::new());
480		let site = Arc::new(AdminSite::new("Registry Admin"));
481		singleton.set_arc(site);
482		let ctx = reinhardt_di::InjectionContext::builder(singleton).build();
483
484		let result =
485			reinhardt_di::Depends::<AdminSiteKey, AdminSite>::resolve_from_registry(&ctx, true)
486				.await;
487
488		assert!(result.is_ok());
489		assert_eq!(result.unwrap().name(), "Registry Admin");
490	}
491
492	#[rstest]
493	fn test_url_prefix() {
494		let mut admin = AdminSite::new("Admin");
495		admin.set_url_prefix("/manage");
496		assert_eq!(admin.url_prefix(), "/manage");
497	}
498
499	#[rstest]
500	fn test_configuration() {
501		let admin = AdminSite::new("Admin");
502		admin.configure(|config| {
503			config.site_title = "Custom Title".into();
504			config.list_per_page = 25;
505		});
506
507		let config = admin.config();
508		assert_eq!(config.site_title, "Custom Title");
509		assert_eq!(config.list_per_page, 25);
510	}
511
512	#[rstest]
513	fn test_register_and_unregister() {
514		let admin = AdminSite::new("Admin");
515		let model_admin = ModelAdminConfig::new("User");
516
517		assert!(!admin.is_registered("User"));
518
519		admin.register("User", model_admin).unwrap();
520		assert!(admin.is_registered("User"));
521		assert_eq!(admin.model_count(), 1);
522
523		admin.unregister("User").unwrap();
524		assert!(!admin.is_registered("User"));
525		assert_eq!(admin.model_count(), 0);
526	}
527
528	#[rstest]
529	fn test_unregister_nonexistent() {
530		let admin = AdminSite::new("Admin");
531		let result = admin.unregister("NonExistent");
532		assert!(result.is_err());
533	}
534
535	#[rstest]
536	fn test_get_model_admin() {
537		let admin = AdminSite::new("Admin");
538		let model_admin = ModelAdminConfig::new("User");
539
540		admin.register("User", model_admin).unwrap();
541
542		let retrieved = admin.get_model_admin("User");
543		assert!(retrieved.is_ok());
544	}
545
546	#[rstest]
547	fn test_get_nonexistent_model_admin() {
548		let admin = AdminSite::new("Admin");
549		let result = admin.get_model_admin("NonExistent");
550		assert!(result.is_err());
551	}
552
553	#[rstest]
554	fn test_registered_models() {
555		let admin = AdminSite::new("Admin");
556
557		admin
558			.register("User", ModelAdminConfig::new("User"))
559			.unwrap();
560		admin
561			.register("Post", ModelAdminConfig::new("Post"))
562			.unwrap();
563
564		let models = admin.registered_models();
565		assert_eq!(models.len(), 2);
566		assert!(models.contains(&"User".into()));
567		assert!(models.contains(&"Post".into()));
568	}
569
570	#[rstest]
571	fn test_clear() {
572		let admin = AdminSite::new("Admin");
573
574		admin
575			.register("User", ModelAdminConfig::new("User"))
576			.unwrap();
577		admin
578			.register("Post", ModelAdminConfig::new("Post"))
579			.unwrap();
580
581		assert_eq!(admin.model_count(), 2);
582
583		admin.clear();
584		assert_eq!(admin.model_count(), 0);
585	}
586
587	#[rstest]
588	fn test_duplicate_registration_returns_error() {
589		// Arrange
590		let admin = AdminSite::new("Admin");
591		admin
592			.register("User", ModelAdminConfig::new("User"))
593			.unwrap();
594
595		// Act
596		let result = admin.register("User", ModelAdminConfig::new("User"));
597
598		// Assert
599		assert!(result.is_err());
600		let err = result.unwrap_err();
601		assert!(err.to_string().contains("already registered"));
602	}
603
604	#[rstest]
605	fn test_default_config() {
606		let config = AdminSiteConfig::default();
607		assert_eq!(config.site_title, "Admin Panel");
608		assert_eq!(config.site_header, "Administration");
609		assert_eq!(config.list_per_page, 100);
610		assert!(config.enable_search);
611		assert!(config.enable_filters);
612	}
613
614	#[rstest]
615	fn test_set_arc_stores_admin_site_with_correct_type_id() {
616		// Arrange
617		let singleton = SingletonScope::new();
618		let site = Arc::new(AdminSite::new("Test Admin"));
619
620		// Act - use set_arc which stores with TypeId::of::<AdminSite>()
621		singleton.set_arc(site);
622
623		// Assert - should be retrievable as AdminSite (not Arc<AdminSite>)
624		assert!(
625			singleton.get::<AdminSite>().is_some(),
626			"AdminSite should be retrievable via get::<AdminSite>()"
627		);
628	}
629
630	#[rstest]
631	fn test_set_arc_preserves_favicon_data() {
632		// Arrange
633		let singleton = SingletonScope::new();
634		let site = Arc::new(AdminSite::new("Test Admin"));
635		let favicon = vec![0x89, 0x50, 0x4E, 0x47]; // PNG magic bytes
636
637		// Act
638		site.set_favicon(favicon.clone());
639		singleton.set_arc(site);
640
641		// Assert
642		let retrieved = singleton.get::<AdminSite>().unwrap();
643		assert_eq!(retrieved.favicon_data(), Some(favicon));
644	}
645
646	#[rstest]
647	#[tokio::test]
648	async fn test_admin_site_inject_resolves_from_singleton() {
649		// Arrange
650		let singleton = Arc::new(SingletonScope::new());
651		let site = Arc::new(AdminSite::new("Injectable Admin"));
652		singleton.set_arc(site);
653		let ctx = reinhardt_di::InjectionContext::builder(singleton).build();
654
655		// Act
656		let result = AdminSite::inject(&ctx).await;
657
658		// Assert
659		assert!(result.is_ok());
660		assert_eq!(result.unwrap().name(), "Injectable Admin");
661	}
662
663	#[rstest]
664	#[tokio::test]
665	async fn test_admin_site_inject_returns_error_when_not_registered() {
666		// Arrange
667		let singleton = Arc::new(SingletonScope::new());
668		let ctx = reinhardt_di::InjectionContext::builder(singleton).build();
669
670		// Act
671		let result = AdminSite::inject(&ctx).await;
672
673		// Assert
674		assert!(result.is_err());
675		let err = result.err().unwrap();
676		assert!(
677			err.to_string().contains("AdminSite"),
678			"Error should mention AdminSite, got: {}",
679			err
680		);
681	}
682
683	#[rstest]
684	#[tokio::test]
685	async fn test_admin_site_inject_error_hint_mentions_routes_with_di() {
686		// Arrange
687		let singleton = Arc::new(SingletonScope::new());
688		let ctx = reinhardt_di::InjectionContext::builder(singleton).build();
689
690		// Act
691		let result = AdminSite::inject(&ctx).await;
692
693		// Assert
694		assert!(result.is_err());
695		let err = result.err().unwrap();
696		assert!(
697			err.to_string().contains("admin_routes_with_di"),
698			"Error hint should mention admin_routes_with_di, got: {}",
699			err
700		);
701	}
702
703	// ---- Case-insensitive registry tests (Fixes #3353) ----
704
705	#[rstest]
706	fn test_get_model_admin_case_insensitive() {
707		let admin = AdminSite::new("Admin");
708		admin
709			.register("User", ModelAdminConfig::new("User"))
710			.unwrap();
711
712		assert!(admin.get_model_admin("User").is_ok());
713		assert!(admin.get_model_admin("user").is_ok());
714		assert!(admin.get_model_admin("USER").is_ok());
715		assert!(admin.get_model_admin("uSeR").is_ok());
716		assert!(admin.get_model_admin("nonexistent").is_err());
717	}
718
719	#[rstest]
720	fn test_is_registered_case_insensitive() {
721		let admin = AdminSite::new("Admin");
722		admin
723			.register("User", ModelAdminConfig::new("User"))
724			.unwrap();
725
726		assert!(admin.is_registered("User"));
727		assert!(admin.is_registered("user"));
728		assert!(admin.is_registered("USER"));
729		assert!(!admin.is_registered("Post"));
730	}
731
732	#[rstest]
733	fn test_register_rejects_case_insensitive_duplicate() {
734		let admin = AdminSite::new("Admin");
735		admin
736			.register("User", ModelAdminConfig::new("User"))
737			.unwrap();
738
739		let result = admin.register("user", ModelAdminConfig::new("user"));
740		assert!(result.is_err());
741		assert!(
742			result
743				.unwrap_err()
744				.to_string()
745				.contains("already registered")
746		);
747	}
748
749	#[rstest]
750	fn test_unregister_case_insensitive() {
751		let admin = AdminSite::new("Admin");
752		admin
753			.register("User", ModelAdminConfig::new("User"))
754			.unwrap();
755
756		admin.unregister("user").unwrap();
757		assert!(!admin.is_registered("User"));
758	}
759}