1use std::path::PathBuf;
29use std::sync::{Arc, Mutex};
30
31use minijinja::{Environment, ErrorKind};
32use serde::Serialize;
33
34use crate::error::{Error, Result};
35
36pub struct Templates {
37 env: Mutex<Environment<'static>>,
38}
39
40impl Templates {
41 pub fn new(project_templates_dir: Option<PathBuf>) -> Result<Arc<Self>> {
48 let disk_root = project_templates_dir;
49 let mut env = Environment::new();
50 env.set_loader(move |name| load_template(disk_root.as_deref(), name));
51
52 env.add_function("icon", |name: &str, kwargs: minijinja::value::Kwargs| {
58 let class: String = kwargs.get("class").unwrap_or_default();
59 kwargs.assert_all_used().ok();
60 minijinja::value::Value::from_safe_string(
63 crate::admin::icons::render_inline(name, &class),
64 )
65 });
66
67 Ok(Arc::new(Self {
68 env: Mutex::new(env),
69 }))
70 }
71
72 pub fn render<S: Serialize>(&self, name: &str, ctx: &S) -> Result<String> {
74 let mut env = self
75 .env
76 .lock()
77 .map_err(|e| Error::Internal(format!("template env poisoned: {e}")))?;
78 env.clear_templates();
80 let tmpl = env
81 .get_template(name)
82 .map_err(|e| Error::Internal(format!("template {name} not found: {e}")))?;
83 tmpl.render(ctx)
84 .map_err(|e| Error::Internal(format!("render {name}: {e}")))
85 }
86
87 #[allow(dead_code)]
94 pub fn render_for_model<S: Serialize>(
95 &self,
96 model: &str,
97 name: &str,
98 ctx: &S,
99 ) -> Result<String> {
100 let page = name.strip_prefix("admin/").unwrap_or(name);
101 let per_model = format!("admin/{model}/{page}");
102 let mut env = self
103 .env
104 .lock()
105 .map_err(|e| Error::Internal(format!("template env poisoned: {e}")))?;
106 env.clear_templates();
107 if let Ok(tmpl) = env.get_template(&per_model) {
109 return tmpl
110 .render(ctx)
111 .map_err(|e| Error::Internal(format!("render {per_model}: {e}")));
112 }
113 let tmpl = env
114 .get_template(name)
115 .map_err(|e| Error::Internal(format!("template {name} not found: {e}")))?;
116 tmpl.render(ctx)
117 .map_err(|e| Error::Internal(format!("render {name}: {e}")))
118 }
119}
120
121fn load_template(
122 disk_root: Option<&std::path::Path>,
123 name: &str,
124) -> std::result::Result<Option<String>, minijinja::Error> {
125 if let Some(root) = disk_root {
126 let path = root.join(name);
127 if path.exists() {
128 return std::fs::read_to_string(&path).map(Some).map_err(|e| {
129 minijinja::Error::new(
130 ErrorKind::InvalidOperation,
131 format!("read template {}: {e}", path.display()),
132 )
133 });
134 }
135 }
136 Ok(EMBEDDED_TEMPLATES
137 .iter()
138 .find_map(|(n, b)| if *n == name { Some((*b).to_string()) } else { None }))
139}
140
141const EMBEDDED_TEMPLATES: &[(&str, &str)] = &[
143 ("base.html", include_str!("../assets/templates/base.html")),
144 ("admin/base.html", include_str!("../assets/templates/admin/base.html")),
145 ("admin/login.html", include_str!("../assets/templates/admin/login.html")),
146 ("admin/index.html", include_str!("../assets/templates/admin/index.html")),
147 ("admin/list.html", include_str!("../assets/templates/admin/list.html")),
148 ("admin/form.html", include_str!("../assets/templates/admin/form.html")),
149 ("admin/confirm_delete.html", include_str!("../assets/templates/admin/confirm_delete.html")),
150 ("admin/object_history.html", include_str!("../assets/templates/admin/object_history.html")),
151 ("admin/log_entries.html", include_str!("../assets/templates/admin/log_entries.html")),
152 ("admin/password_change.html", include_str!("../assets/templates/admin/password_change.html")),
153 ("admin/users_list.html", include_str!("../assets/templates/admin/users_list.html")),
154 ("admin/user_edit.html", include_str!("../assets/templates/admin/user_edit.html")),
155 ("admin/user_new.html", include_str!("../assets/templates/admin/user_new.html")),
156 ("admin/user_view.html", include_str!("../assets/templates/admin/user_view.html")),
157 ("admin/user_confirm_delete.html", include_str!("../assets/templates/admin/user_confirm_delete.html")),
158 ("admin/groups_list.html", include_str!("../assets/templates/admin/groups_list.html")),
159 ("admin/group_edit.html", include_str!("../assets/templates/admin/group_edit.html")),
160 ("admin/group_new.html", include_str!("../assets/templates/admin/group_new.html")),
161 ("admin/group_confirm_delete.html", include_str!("../assets/templates/admin/group_confirm_delete.html")),
162 ("admin/forbidden.html", include_str!("../assets/templates/admin/forbidden.html")),
163 ("admin/error.html", include_str!("../assets/templates/admin/error.html")),
164 ("admin/coming_soon.html", include_str!("../assets/templates/admin/coming_soon.html")),
165 ("admin/includes/_field_errors.html", include_str!("../assets/templates/admin/includes/_field_errors.html")),
166 ("admin/includes/_form_field.html", include_str!("../assets/templates/admin/includes/_form_field.html")),
167 ("search.html", include_str!("../assets/templates/search.html")),
168];
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 use serde::Serialize;
174 use std::io::Write;
175
176 #[derive(Serialize)]
177 struct Empty {}
178
179 #[test]
180 fn loader_registers_all_embedded_templates() {
181 let t = Templates::new(None).unwrap();
182 assert!(t.render("base.html", &Empty {}).is_ok());
183 }
184
185 #[test]
186 fn missing_template_errors_cleanly() {
187 let t = Templates::new(None).unwrap();
188 let err = t.render("does/not/exist.html", &Empty {}).unwrap_err();
189 assert_eq!(err.status(), 500);
190 }
191
192 #[test]
193 fn disk_override_wins_over_embedded() {
194 let dir = tempdir();
195 let admin_dir = dir.join("admin");
196 std::fs::create_dir_all(&admin_dir).unwrap();
197 let mut f = std::fs::File::create(admin_dir.join("login.html")).unwrap();
198 f.write_all(b"OVERRIDDEN-BODY").unwrap();
199 drop(f);
200
201 let t = Templates::new(Some(dir.clone())).unwrap();
202 let body = t.render("admin/login.html", &Empty {}).unwrap();
203 assert_eq!(body, "OVERRIDDEN-BODY");
204
205 let _ = std::fs::remove_dir_all(&dir);
207 }
208
209 #[test]
210 fn embedded_fallback_when_disk_missing() {
211 let dir = tempdir();
212 let t = Templates::new(Some(dir.clone())).unwrap();
214 let body = t.render("admin/login.html", &Empty {}).unwrap();
215 assert!(!body.is_empty());
218 assert!(!body.contains("OVERRIDDEN-BODY"));
219
220 let _ = std::fs::remove_dir_all(&dir);
221 }
222
223 #[test]
224 fn live_edit_visible_on_next_render_without_restart() {
225 let dir = tempdir();
228 let admin_dir = dir.join("admin");
229 std::fs::create_dir_all(&admin_dir).unwrap();
230 let target = admin_dir.join("login.html");
231
232 std::fs::write(&target, b"V1").unwrap();
233 let t = Templates::new(Some(dir.clone())).unwrap();
234 assert_eq!(t.render("admin/login.html", &Empty {}).unwrap(), "V1");
235
236 std::fs::write(&target, b"V2").unwrap();
238 assert_eq!(
239 t.render("admin/login.html", &Empty {}).unwrap(),
240 "V2",
241 "loader must re-resolve from disk on every render"
242 );
243
244 let _ = std::fs::remove_dir_all(&dir);
245 }
246
247 #[test]
256 fn user_confirm_delete_renders_with_last_developer_banner() {
257 let t = Templates::new(None).unwrap();
258
259 let ctx = serde_json::json!({
261 "user_id": 122,
262 "email": "backup@example.com",
263 "role": "developer",
264 "group_count": 0,
265 "session_count": 1,
266 "direct_perm_count": 0,
267 "is_self": false,
268 "is_last_developer": true,
269 "csrf_token": "test-csrf",
270 });
271 let body = t.render("admin/user_confirm_delete.html", &ctx).unwrap();
272 assert!(
273 body.contains("is the last active developer"),
274 "last-dev banner must mention the orphan condition"
275 );
276 assert!(
277 body.contains("rustio-cli user role set"),
278 "last-dev banner must point operators at the CLI escape hatch"
279 );
280 assert!(
281 body.contains(r#"<button type="submit" class="btn-danger" disabled>"#),
282 "submit must be disabled when target is the last active developer"
283 );
284
285 let ctx_self = serde_json::json!({
287 "user_id": 7,
288 "email": "me@rustio.local",
289 "role": "administrator",
290 "group_count": 2,
291 "session_count": 1,
292 "direct_perm_count": 0,
293 "is_self": true,
294 "is_last_developer": false,
295 "csrf_token": "test-csrf",
296 });
297 let body = t.render("admin/user_confirm_delete.html", &ctx_self).unwrap();
298 assert!(
299 body.contains("your own account"),
300 "self-delete banner must call out the self-action"
301 );
302 assert!(
303 body.contains(r#"<button type="submit" class="btn-danger" disabled>"#),
304 "submit must be disabled on self-delete"
305 );
306 }
307
308 fn view_ctx_base() -> serde_json::Value {
325 serde_json::json!({
326 "csrf_token": "test-csrf",
327 "user": {
328 "id": 42,
329 "email": "alice@example.com",
330 "full_name": "Alice",
331 "full_name_value": null,
332 "role": "Staff",
333 "is_admin": false,
334 "is_developer": false,
335 "is_active": true,
336 "is_demo": false,
337 "demo_label": null,
338 "locale": null,
339 "timezone": null,
340 "created_at_iso": "2026-04-25 12:00 UTC",
341 "last_seen_relative": "just now",
342 "last_login_iso": "2026-04-25 11:50 UTC",
343 "groups": [],
344 },
345 "users": [],
346 "total": 1,
347 "activity_count": 0,
348 "permission_count": 0,
349 "session_count": 0,
350 "tab": "overview",
351 "recent_events": [],
352 "activity_page": 1,
353 "activity_total_pages": 1,
354 "permissions": [],
355 "sessions": [],
356 "project_fields": [],
357 "can_edit": true,
358 })
359 }
360
361 #[test]
366 fn icon_function_emits_inline_svg() {
367 let dir = tempdir();
369 let admin_dir = dir.join("admin");
370 std::fs::create_dir_all(&admin_dir).unwrap();
371 std::fs::write(
372 admin_dir.join("icon_test.html"),
373 r#"<div>{{ icon("home", class="sidebar-icon") }}</div>"#,
374 )
375 .unwrap();
376
377 let t = Templates::new(Some(dir.clone())).unwrap();
378 let body = t.render("admin/icon_test.html", &Empty {}).unwrap();
379 assert!(
380 body.contains("<svg"),
381 "icon() must emit raw <svg> markup, not escape it"
382 );
383 assert!(body.contains(r#"class="sidebar-icon""#));
384 assert!(body.contains(r#"viewBox="0 0 24 24""#));
385 assert!(body.contains(r#"stroke="currentColor""#));
386
387 std::fs::write(
389 admin_dir.join("icon_missing.html"),
390 r#"<span>{{ icon("not-real") }}</span>"#,
391 )
392 .unwrap();
393 let body = t.render("admin/icon_missing.html", &Empty {}).unwrap();
394 assert_eq!(body.trim(), "<span></span>", "missing icon must be silent");
395
396 let _ = std::fs::remove_dir_all(&dir);
397 }
398
399 #[test]
403 fn user_view_overview_renders_with_groups() {
404 let t = Templates::new(None).unwrap();
405 let mut ctx = view_ctx_base();
406 ctx["user"]["groups"] = serde_json::json!(["Auditors", "Content Editors"]);
407 let body = t.render("admin/user_view.html", &ctx).unwrap();
408
409 assert!(body.contains("class=\"splitview\""), "must render the splitview shell");
411 assert!(body.contains("class=\"show-grid\""), "Overview must render the show-grid");
412 assert!(body.contains("class=\"stat-strip\""), "Overview must render the stat-strip");
413
414 assert!(body.contains("<code>Auditors</code>"));
416 assert!(body.contains("<code>Content Editors</code>"));
417 }
418
419 #[test]
422 fn user_view_overview_without_groups_shows_empty_marker() {
423 let t = Templates::new(None).unwrap();
424 let body = t.render("admin/user_view.html", &view_ctx_base()).unwrap();
425 assert!(
426 body.contains("No groups"),
427 "empty-groups copy must appear in the show-grid Groups row"
428 );
429 }
430
431 #[test]
436 fn user_view_activity_tab_renders_pager() {
437 let t = Templates::new(None).unwrap();
438 let mut ctx = view_ctx_base();
439 ctx["tab"] = serde_json::json!("activity");
440 ctx["activity_count"] = serde_json::json!(120);
441 ctx["activity_page"] = serde_json::json!(2);
442 ctx["activity_total_pages"] = serde_json::json!(3);
443 ctx["recent_events"] = serde_json::json!([
444 { "id": 1, "kind": "info", "message": "Updated <strong>posts</strong> #5",
445 "timestamp_relative": "1h ago", "actor": "user:42" },
446 { "id": 2, "kind": "success", "message": "Created <strong>posts</strong> #5",
447 "timestamp_relative": "2h ago", "actor": "user:42" },
448 ]);
449 let body = t.render("admin/user_view.html", &ctx).unwrap();
450
451 assert!(body.contains("class=\"timeline\""), "Activity must render the timeline component");
452 assert!(body.contains("tl-info"), "event kind must drive the dot color class");
453 assert!(body.contains("class=\"pager\""), "Activity must render the pager when total_pages > 1");
454 assert!(body.contains("?tab=activity&page=1"), "pager must link Prev to page-1");
455 assert!(body.contains("?tab=activity&page=3"), "pager must link Next to page+1");
456 assert!(!body.contains("?tab=overview&page="), "Overview tab link must strip page param");
458 assert!(!body.contains("?tab=permissions&page="), "Permissions tab link must strip page param");
459 assert!(!body.contains("?tab=sessions&page="), "Sessions tab link must strip page param");
460 }
461
462 #[test]
465 fn user_view_permissions_tab_renders_with_sources() {
466 let t = Templates::new(None).unwrap();
467 let mut ctx = view_ctx_base();
468 ctx["tab"] = serde_json::json!("permissions");
469 ctx["permissions"] = serde_json::json!([
470 { "name": "posts.add_post", "source": "direct" },
471 { "name": "posts.change_post", "source": "via Editors" },
472 ]);
473 let body = t.render("admin/user_view.html", &ctx).unwrap();
474
475 assert!(body.contains("class=\"perm-grid\""), "Permissions must render perm-grid");
476 assert!(body.contains("posts.add_post"));
477 assert!(body.contains("posts.change_post"));
478 assert!(body.contains("direct"), "direct grant must show the 'direct' source chip");
479 assert!(body.contains("via Editors"), "inherited grant must show the source group");
480 }
481
482 #[test]
486 fn user_view_sessions_tab_truncates_token_and_handles_nulls() {
487 let t = Templates::new(None).unwrap();
488 let mut ctx = view_ctx_base();
489 ctx["tab"] = serde_json::json!("sessions");
490 ctx["sessions"] = serde_json::json!([
491 {
492 "token_short": "abc1234",
493 "created_at_iso": "2026-05-01 10:00 UTC",
494 "last_seen_relative": "5m ago",
495 "ip": null,
496 "user_agent": null,
497 },
498 ]);
499 let body = t.render("admin/user_view.html", &ctx).unwrap();
500
501 assert!(body.contains("<table class=\"table\""), "Sessions must render the table");
502 assert!(body.contains("<code>abc1234</code>"), "token must render truncated, never full-length");
503 assert!(body.contains("rio-cell-empty"), "absent IP / UA must render as the empty marker");
505 }
506
507 #[test]
512 fn users_list_renders_row_clickable_links() {
513 let t = Templates::new(None).unwrap();
514 let ctx = serde_json::json!({
515 "page_title": "Users",
516 "users": [
517 { "id": 7, "email": "alice@example.com", "role": "staff",
518 "is_active": true, "created_at": "2026-04-01" },
519 { "id": 9, "email": "bob@example.com", "role": "developer",
520 "is_active": false, "created_at": "2026-04-02" },
521 ],
522 "csrf_token": "x",
523 });
524 let body = t.render("admin/users_list.html", &ctx).unwrap();
525
526 assert!(body.contains(r#"class="results row-clickable""#));
528
529 let count_for = |needle: &str| body.matches(needle).count();
531 assert_eq!(
532 count_for(r#"href="/admin/users/7/""#),
533 4,
534 "every cell in row 7 must link to the profile view (4 anchors)"
535 );
536 assert_eq!(
537 count_for(r#"href="/admin/users/9/""#),
538 4,
539 "every cell in row 9 must link to the profile view (4 anchors)"
540 );
541 assert_eq!(
544 count_for(r#"href="/admin/users/7/edit""#),
545 0,
546 "list rows must NOT link to /edit anymore — that lives behind the view"
547 );
548 }
549
550 #[test]
556 fn user_view_overview_renders_project_fields_section() {
557 let t = Templates::new(None).unwrap();
558 let mut ctx = view_ctx_base();
559 ctx["project_fields"] = serde_json::json!([
560 {
561 "label": "Halal certification",
562 "rows": [
563 { "label": "Certified by", "value": "ICCV Halal Authority" },
564 { "label": "License #", "value": "HC-2025-0042" },
565 ],
566 },
567 ]);
568 let body = t.render("admin/user_view.html", &ctx).unwrap();
569
570 assert!(body.contains("Halal certification"), "section label must render");
571 assert!(body.contains("Certified by"));
572 assert!(body.contains("ICCV Halal Authority"));
573 assert!(body.contains("License #"));
574 assert!(body.contains("HC-2025-0042"));
575 }
576
577 #[test]
581 fn user_view_overview_omits_extension_when_project_fields_empty() {
582 let t = Templates::new(None).unwrap();
583 let body = t.render("admin/user_view.html", &view_ctx_base()).unwrap();
584 assert!(
587 !body.contains("Halal certification"),
588 "no extension means no project section heading"
589 );
590 }
591
592 #[test]
596 fn user_view_demo_badge_renders_only_for_demo_users() {
597 let t = Templates::new(None).unwrap();
598 let body = t.render("admin/user_view.html", &view_ctx_base()).unwrap();
600 assert!(
601 !body.contains(">DEMO"),
602 "DEMO badge must NOT render for a real user"
603 );
604
605 let mut demo_ctx = view_ctx_base();
607 demo_ctx["user"]["is_demo"] = serde_json::Value::Bool(true);
608 demo_ctx["user"]["demo_label"] = serde_json::Value::String("staff @ rustio.local".into());
609 let demo_body = t.render("admin/user_view.html", &demo_ctx).unwrap();
610 assert!(
611 demo_body.contains(">DEMO"),
612 "DEMO badge must render for a demo user"
613 );
614 assert!(
615 demo_body.contains("staff @ rustio.local"),
616 "demo label must appear in the badge"
617 );
618 }
619
620 #[test]
625 fn user_confirm_delete_submit_enabled_for_normal_user() {
626 let t = Templates::new(None).unwrap();
627 let ctx = serde_json::json!({
628 "user_id": 99,
629 "email": "throwaway@example.com",
630 "role": "staff",
631 "group_count": 0,
632 "session_count": 0,
633 "direct_perm_count": 0,
634 "is_self": false,
635 "is_last_developer": false,
636 "csrf_token": "test-csrf",
637 });
638 let body = t.render("admin/user_confirm_delete.html", &ctx).unwrap();
639 assert!(
644 body.contains(r#"<button type="submit" class="btn-danger">"#),
645 "submit button must render with btn-danger class"
646 );
647 assert!(
650 !body.contains(r#"<button type="submit" class="deletelink-button" disabled>"#),
651 "submit button must NOT render with `disabled` for a deletable user"
652 );
653 assert!(!body.contains("is the last active developer"));
655 assert!(!body.contains("your own account"));
656 }
657
658 fn tempdir() -> PathBuf {
659 let pid = std::process::id();
660 let nonce: u64 = std::time::SystemTime::now()
661 .duration_since(std::time::UNIX_EPOCH)
662 .unwrap()
663 .as_nanos() as u64;
664 let path = std::env::temp_dir().join(format!("rustio-tpl-{pid}-{nonce}"));
665 std::fs::create_dir_all(&path).unwrap();
666 path
667 }
668}