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 {
308 accent: "#2563EB".into(),
309 bg: "#F4F6FB".into(),
310 surface: "#FFFFFF".into(),
311 text: "#111827".into(),
312 text_muted: "#4B5563".into(),
313 border: "#D1D5DB".into(),
314 }
315 }
316}
317
318pub struct Admin {
321 pub(crate) entries: Vec<AdminEntry>,
322 pub(crate) site_branding: SiteBranding,
323 pub(crate) user_profile_ext: Option<UserProfileExtensionFn>,
324 pub(crate) theme: AdminTheme,
325}
326
327impl Default for Admin {
328 fn default() -> Self {
329 Self::new()
330 }
331}
332
333impl Admin {
334 pub fn new() -> Self {
338 Self {
339 entries: vec![core_user_entry()],
340 site_branding: SiteBranding::default(),
341 user_profile_ext: None,
342 theme: AdminTheme::default(),
343 }
344 }
345
346 pub fn site_branding(mut self, branding: SiteBranding) -> Self {
348 self.site_branding = branding;
349 self
350 }
351
352 pub fn branding(&self) -> &SiteBranding {
354 &self.site_branding
355 }
356
357 pub fn accent_color(mut self, color: impl Into<String>) -> Self {
360 self.theme.accent = normalise_hex(color);
361 self
362 }
363
364 pub fn theme(mut self, theme: AdminTheme) -> Self {
367 self.theme = theme;
368 self
369 }
370
371 pub fn accent(&self) -> &str {
373 &self.theme.accent
374 }
375
376 pub fn active_theme(&self) -> &AdminTheme {
378 &self.theme
379 }
380
381 pub fn model<M>(mut self) -> Self
382 where
383 M: super::ModelAdmin + crate::orm::Model,
384 {
385 let ops: Arc<dyn AdminOps> = Arc::new(super::ops::ConcreteOps::<M>::new());
386 self.entries.push(AdminEntry {
387 admin_name: M::ADMIN_NAME,
388 display_name: M::DISPLAY_NAME,
389 singular_name: M::SINGULAR_NAME,
390 table: <M as crate::orm::Model>::TABLE,
391 fields: M::FIELDS,
392 core: false,
393 list_display: M::list_display(),
394 list_filter: M::list_filter(),
395 search_fields: M::search_fields(),
396 ordering: M::ordering(),
397 list_per_page: M::list_per_page(),
398 readonly_fields: M::readonly_fields(),
399 fieldsets: M::fieldsets(),
400 ops,
401 });
402 self
403 }
404
405 pub fn entries(&self) -> &[AdminEntry] {
406 &self.entries
407 }
408
409 pub fn user_profile_extension<F, Fut>(mut self, ext: F) -> Self
422 where
423 F: Fn(Db, crate::auth::UserProfile) -> Fut + Send + Sync + 'static,
424 Fut: Future<Output = Result<Vec<UserProfileSection>>> + Send + 'static,
425 {
426 self.user_profile_ext = Some(Arc::new(move |db, user| Box::pin(ext(db, user))));
427 self
428 }
429
430 #[allow(dead_code)]
433 pub(crate) fn user_profile_ext(&self) -> Option<&UserProfileExtensionFn> {
434 self.user_profile_ext.as_ref()
435 }
436
437 pub fn find(&self, admin_name: &str) -> Option<&AdminEntry> {
438 self.entries.iter().find(|e| e.admin_name == admin_name)
439 }
440
441 pub async fn seed_permissions(&self, db: &crate::orm::Db) -> crate::error::Result<()> {
444 for entry in &self.entries {
445 let singular = entry.singular_name.to_ascii_lowercase();
446 crate::auth::register_model_permissions(db, entry.admin_name, &singular).await?;
447 }
448 Ok(())
449 }
450}
451
452const CORE_USER_FIELDS: &[AdminField] = &[
464 AdminField {
465 name: "id",
466 label: "id",
467 field_type: FieldType::I64,
468 editable: false,
469 relation: None,
470 choices: None,
471 },
472 AdminField {
473 name: "email",
474 label: "email",
475 field_type: FieldType::String,
476 editable: true,
477 relation: None,
478 choices: None,
479 },
480 AdminField {
481 name: "password_hash",
482 label: "password_hash",
483 field_type: FieldType::String,
484 editable: false,
485 relation: None,
486 choices: None,
487 },
488 AdminField {
489 name: "role",
490 label: "role",
491 field_type: FieldType::String,
492 editable: true,
493 relation: None,
494 choices: None,
495 },
496 AdminField {
497 name: "is_active",
498 label: "is_active",
499 field_type: FieldType::Bool,
500 editable: true,
501 relation: None,
502 choices: None,
503 },
504 AdminField {
505 name: "created_at",
506 label: "created_at",
507 field_type: FieldType::DateTime,
508 editable: false,
509 relation: None,
510 choices: None,
511 },
512];
513
514pub(crate) fn normalise_hex(input: impl Into<String>) -> String {
520 let raw = input.into();
521 let trimmed = raw.trim().trim_start_matches('#');
522 format!("#{trimmed}")
523}
524
525fn core_user_entry() -> AdminEntry {
526 AdminEntry {
527 admin_name: "users",
528 display_name: "Users",
529 singular_name: "User",
530 table: "rustio_users",
531 fields: CORE_USER_FIELDS,
532 core: true,
533 list_display: &[],
534 list_filter: &[],
535 search_fields: &[],
536 ordering: &["-id"],
537 list_per_page: 50,
538 readonly_fields: &[],
539 fieldsets: &[],
540 ops: Arc::new(CoreUserOps),
541 }
542}
543
544struct CoreUserOps;
550
551fn core_user_route_error() -> crate::error::Error {
552 crate::error::Error::Internal(
553 "the core User entry is route-only — use the dedicated /admin/users page".into(),
554 )
555}
556
557impl AdminOps for CoreUserOps {
558 fn list<'a>(
559 &'a self,
560 _db: &'a Db,
561 _opts: ListOpts,
562 ) -> Pin<Box<dyn Future<Output = Result<ListPage>> + Send + 'a>> {
563 Box::pin(async { Err(core_user_route_error()) })
564 }
565
566 fn find_row<'a>(
567 &'a self,
568 _db: &'a Db,
569 _id: i64,
570 ) -> Pin<Box<dyn Future<Output = Result<Option<EditRow>>> + Send + 'a>> {
571 Box::pin(async { Err(core_user_route_error()) })
572 }
573
574 fn create<'a>(&'a self, _db: &'a Db, _form: &'a FormData) -> CreateResult<'a> {
575 Box::pin(async { Err(core_user_route_error()) })
576 }
577
578 fn update<'a>(&'a self, _db: &'a Db, _id: i64, _form: &'a FormData) -> UpdateResult<'a> {
579 Box::pin(async { Err(core_user_route_error()) })
580 }
581
582 fn delete<'a>(
583 &'a self,
584 _db: &'a Db,
585 _id: i64,
586 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
587 Box::pin(async { Err(core_user_route_error()) })
588 }
589
590 fn object_label<'a>(
591 &'a self,
592 _db: &'a Db,
593 _id: i64,
594 ) -> Pin<Box<dyn Future<Output = Result<Option<String>>> + Send + 'a>> {
595 Box::pin(async { Err(core_user_route_error()) })
596 }
597}
598
599