1use std::future::Future;
10use std::pin::Pin;
11use std::sync::Arc;
12
13use crate::error::Result;
14use crate::http::FormData;
15use crate::orm::{Db, Value};
16
17pub(crate) type CreateResult<'a> =
18 Pin<Box<dyn Future<Output = Result<std::result::Result<i64, Vec<String>>>> + Send + 'a>>;
19
20pub(crate) type UpdateResult<'a> =
21 Pin<Box<dyn Future<Output = Result<std::result::Result<(), Vec<String>>>> + Send + 'a>>;
22
23#[derive(Debug, Clone, serde::Serialize)]
33pub struct UserProfileSection {
34 pub label: String,
35 pub rows: Vec<UserProfileRow>,
36}
37
38#[derive(Debug, Clone, serde::Serialize)]
43pub struct UserProfileRow {
44 pub label: String,
45 pub value: String,
46}
47
48pub(crate) type UserProfileExtensionFn =
52 Arc<dyn Fn(Db, crate::auth::UserProfile) -> UserProfileExtensionFuture + Send + Sync + 'static>;
53
54pub(crate) type UserProfileExtensionFuture =
55 Pin<Box<dyn Future<Output = Result<Vec<UserProfileSection>>> + Send + 'static>>;
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum FieldType {
60 I32,
61 I64,
62 Bool,
63 String,
64 DateTime,
65 OptionalI64,
66 OptionalString,
67 OptionalDateTime,
68}
69
70impl FieldType {
71 pub fn widget(&self) -> &'static str {
72 match self {
73 FieldType::Bool => "checkbox",
74 FieldType::DateTime | FieldType::OptionalDateTime => "datetime",
75 FieldType::I32 | FieldType::I64 | FieldType::OptionalI64 => "number",
76 FieldType::String | FieldType::OptionalString => "text",
77 }
78 }
79
80 pub fn nullable(&self) -> bool {
81 matches!(
82 self,
83 FieldType::OptionalI64 | FieldType::OptionalString | FieldType::OptionalDateTime
84 )
85 }
86}
87
88#[derive(Debug, Clone)]
89pub struct AdminField {
90 pub name: &'static str,
91 pub label: &'static str,
92 pub field_type: FieldType,
93 pub editable: bool,
94 pub relation: Option<AdminRelation>,
95 pub choices: Option<&'static [&'static str]>,
100}
101
102#[derive(Debug, Clone)]
103pub struct AdminRelation {
104 pub target_model: &'static str,
105 pub display_field: Option<&'static str>,
106 pub multi: bool,
112}
113
114pub trait AdminModel: Send + Sync + 'static {
116 const ADMIN_NAME: &'static str;
117 const DISPLAY_NAME: &'static str;
118 const SINGULAR_NAME: &'static str;
119 const FIELDS: &'static [AdminField];
120
121 fn display_values(&self) -> Vec<(String, String)>;
123
124 fn from_form(form: &FormData) -> std::result::Result<Self, Vec<String>>
127 where
128 Self: Sized;
129
130 fn object_label(&self) -> String;
132
133 fn id(&self) -> i64;
134
135 fn values_to_update(&self) -> Vec<(&'static str, Value)>;
136}
137
138pub struct AdminEntry {
143 pub admin_name: &'static str,
144 pub display_name: &'static str,
145 pub singular_name: &'static str,
146 pub table: &'static str,
149 pub fields: &'static [AdminField],
150 pub core: bool,
152 pub list_display: &'static [&'static str],
155 pub list_filter: &'static [&'static str],
157 pub search_fields: &'static [&'static str],
159 pub ordering: &'static [&'static str],
162 pub list_per_page: usize,
164 pub readonly_fields: &'static [&'static str],
166 pub fieldsets: &'static [super::modeladmin::Fieldset],
169 pub(crate) ops: Arc<dyn AdminOps>,
170}
171
172#[derive(Debug, Clone, Default)]
177pub struct ListOpts {
178 pub ordering: Vec<(String, super::modeladmin::SortDir)>,
183 pub filters: Vec<(String, String)>,
188 pub search: Option<(String, Vec<String>)>,
193 pub limit: Option<i64>,
196 pub offset: Option<i64>,
198}
199
200#[derive(Debug, Default)]
204pub struct ListPage {
205 pub rows: Vec<ListRow>,
206 pub total: i64,
207}
208
209pub(crate) trait AdminOps: Send + Sync {
214 fn list<'a>(
215 &'a self,
216 db: &'a Db,
217 opts: ListOpts,
218 ) -> Pin<Box<dyn Future<Output = Result<ListPage>> + Send + 'a>>;
219
220 fn find_row<'a>(
221 &'a self,
222 db: &'a Db,
223 id: i64,
224 ) -> Pin<Box<dyn Future<Output = Result<Option<EditRow>>> + Send + 'a>>;
225
226 fn create<'a>(&'a self, db: &'a Db, form: &'a FormData) -> CreateResult<'a>;
227
228 fn update<'a>(&'a self, db: &'a Db, id: i64, form: &'a FormData) -> UpdateResult<'a>;
229
230 fn delete<'a>(
231 &'a self,
232 db: &'a Db,
233 id: i64,
234 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
235
236 fn object_label<'a>(
237 &'a self,
238 db: &'a Db,
239 id: i64,
240 ) -> Pin<Box<dyn Future<Output = Result<Option<String>>> + Send + 'a>>;
241}
242
243#[derive(Debug)]
245pub struct ListRow {
246 pub id: i64,
247 pub cells: Vec<String>,
248}
249
250#[derive(Debug)]
252pub struct EditRow {
253 #[allow(dead_code)]
254 pub id: i64,
255 pub values: Vec<(String, String)>,
256}
257
258#[derive(Clone, Debug)]
261pub struct SiteBranding {
262 pub site_title: String,
263 pub site_header: String,
264 pub index_title: String,
265 pub footer_copyright: String,
266 pub domain: String,
269}
270
271impl Default for SiteBranding {
272 fn default() -> Self {
273 Self {
274 site_title: "RustIO administration".into(),
275 site_header: "RustIO administration".into(),
276 index_title: "Site administration".into(),
277 footer_copyright: format!("RustIO {}", env!("CARGO_PKG_VERSION")),
278 domain: "rustio.local".into(),
279 }
280 }
281}
282
283#[derive(Clone, Debug, PartialEq, Eq)]
295pub struct AdminTheme {
296 pub accent: String,
297 pub bg: String,
298 pub surface: String,
299 pub text: String,
300 pub text_muted: String,
301 pub border: String,
302}
303
304impl Default for AdminTheme {
305 fn default() -> Self {
306 Self {
309 accent: "#A0341A".into(),
310 bg: "#EBEEF4".into(),
311 surface: "#FFFFFF".into(),
312 text: "#0A0E1A".into(),
313 text_muted: "#3D4452".into(),
314 border: "#CDD3DF".into(),
315 }
316 }
317}
318
319pub struct Admin {
322 pub(crate) entries: Vec<AdminEntry>,
323 pub(crate) site_branding: SiteBranding,
324 pub(crate) user_profile_ext: Option<UserProfileExtensionFn>,
325 pub(crate) theme: AdminTheme,
326}
327
328impl Default for Admin {
329 fn default() -> Self {
330 Self::new()
331 }
332}
333
334impl Admin {
335 pub fn new() -> Self {
339 Self {
340 entries: vec![core_user_entry()],
341 site_branding: SiteBranding::default(),
342 user_profile_ext: None,
343 theme: AdminTheme::default(),
344 }
345 }
346
347 pub fn site_branding(mut self, branding: SiteBranding) -> Self {
349 self.site_branding = branding;
350 self
351 }
352
353 pub fn branding(&self) -> &SiteBranding {
355 &self.site_branding
356 }
357
358 pub fn accent_color(mut self, color: impl Into<String>) -> Self {
361 self.theme.accent = normalise_hex(color);
362 self
363 }
364
365 pub fn theme(mut self, theme: AdminTheme) -> Self {
368 self.theme = theme;
369 self
370 }
371
372 pub fn accent(&self) -> &str {
374 &self.theme.accent
375 }
376
377 pub fn active_theme(&self) -> &AdminTheme {
379 &self.theme
380 }
381
382 pub fn model<M>(mut self) -> Self
383 where
384 M: super::ModelAdmin + crate::orm::Model,
385 {
386 let ops: Arc<dyn AdminOps> = Arc::new(super::ops::ConcreteOps::<M>::new());
387 self.entries.push(AdminEntry {
388 admin_name: M::ADMIN_NAME,
389 display_name: M::DISPLAY_NAME,
390 singular_name: M::SINGULAR_NAME,
391 table: <M as crate::orm::Model>::TABLE,
392 fields: M::FIELDS,
393 core: false,
394 list_display: M::list_display(),
395 list_filter: M::list_filter(),
396 search_fields: M::search_fields(),
397 ordering: M::ordering(),
398 list_per_page: M::list_per_page(),
399 readonly_fields: M::readonly_fields(),
400 fieldsets: M::fieldsets(),
401 ops,
402 });
403 self
404 }
405
406 pub fn entries(&self) -> &[AdminEntry] {
407 &self.entries
408 }
409
410 pub fn user_profile_extension<F, Fut>(mut self, ext: F) -> Self
423 where
424 F: Fn(Db, crate::auth::UserProfile) -> Fut + Send + Sync + 'static,
425 Fut: Future<Output = Result<Vec<UserProfileSection>>> + Send + 'static,
426 {
427 self.user_profile_ext = Some(Arc::new(move |db, user| Box::pin(ext(db, user))));
428 self
429 }
430
431 #[allow(dead_code)]
434 pub(crate) fn user_profile_ext(&self) -> Option<&UserProfileExtensionFn> {
435 self.user_profile_ext.as_ref()
436 }
437
438 pub fn find(&self, admin_name: &str) -> Option<&AdminEntry> {
439 self.entries.iter().find(|e| e.admin_name == admin_name)
440 }
441
442 pub async fn seed_permissions(&self, db: &crate::orm::Db) -> crate::error::Result<()> {
445 for entry in &self.entries {
446 let singular = entry.singular_name.to_ascii_lowercase();
447 crate::auth::register_model_permissions(db, entry.admin_name, &singular).await?;
448 }
449 Ok(())
450 }
451}
452
453const CORE_USER_FIELDS: &[AdminField] = &[
465 AdminField {
466 name: "id",
467 label: "id",
468 field_type: FieldType::I64,
469 editable: false,
470 relation: None,
471 choices: None,
472 },
473 AdminField {
474 name: "email",
475 label: "email",
476 field_type: FieldType::String,
477 editable: true,
478 relation: None,
479 choices: None,
480 },
481 AdminField {
482 name: "password_hash",
483 label: "password_hash",
484 field_type: FieldType::String,
485 editable: false,
486 relation: None,
487 choices: None,
488 },
489 AdminField {
490 name: "role",
491 label: "role",
492 field_type: FieldType::String,
493 editable: true,
494 relation: None,
495 choices: None,
496 },
497 AdminField {
498 name: "is_active",
499 label: "is_active",
500 field_type: FieldType::Bool,
501 editable: true,
502 relation: None,
503 choices: None,
504 },
505 AdminField {
506 name: "created_at",
507 label: "created_at",
508 field_type: FieldType::DateTime,
509 editable: false,
510 relation: None,
511 choices: None,
512 },
513];
514
515pub(crate) fn normalise_hex(input: impl Into<String>) -> String {
521 let raw = input.into();
522 let trimmed = raw.trim().trim_start_matches('#');
523 format!("#{trimmed}")
524}
525
526fn core_user_entry() -> AdminEntry {
527 AdminEntry {
528 admin_name: "users",
529 display_name: "Users",
530 singular_name: "User",
531 table: "rustio_users",
532 fields: CORE_USER_FIELDS,
533 core: true,
534 list_display: &[],
535 list_filter: &[],
536 search_fields: &[],
537 ordering: &["-id"],
538 list_per_page: 50,
539 readonly_fields: &[],
540 fieldsets: &[],
541 ops: Arc::new(CoreUserOps),
542 }
543}
544
545struct CoreUserOps;
551
552fn core_user_route_error() -> crate::error::Error {
553 crate::error::Error::Internal(
554 "the core User entry is route-only — use the dedicated /admin/users page".into(),
555 )
556}
557
558impl AdminOps for CoreUserOps {
559 fn list<'a>(
560 &'a self,
561 _db: &'a Db,
562 _opts: ListOpts,
563 ) -> Pin<Box<dyn Future<Output = Result<ListPage>> + Send + 'a>> {
564 Box::pin(async { Err(core_user_route_error()) })
565 }
566
567 fn find_row<'a>(
568 &'a self,
569 _db: &'a Db,
570 _id: i64,
571 ) -> Pin<Box<dyn Future<Output = Result<Option<EditRow>>> + Send + 'a>> {
572 Box::pin(async { Err(core_user_route_error()) })
573 }
574
575 fn create<'a>(&'a self, _db: &'a Db, _form: &'a FormData) -> CreateResult<'a> {
576 Box::pin(async { Err(core_user_route_error()) })
577 }
578
579 fn update<'a>(&'a self, _db: &'a Db, _id: i64, _form: &'a FormData) -> UpdateResult<'a> {
580 Box::pin(async { Err(core_user_route_error()) })
581 }
582
583 fn delete<'a>(
584 &'a self,
585 _db: &'a Db,
586 _id: i64,
587 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
588 Box::pin(async { Err(core_user_route_error()) })
589 }
590
591 fn object_label<'a>(
592 &'a self,
593 _db: &'a Db,
594 _id: i64,
595 ) -> Pin<Box<dyn Future<Output = Result<Option<String>>> + Send + 'a>> {
596 Box::pin(async { Err(core_user_route_error()) })
597 }
598}
599
600