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 {
322 serde_json::json!({
323 "target_id": 42,
324 "target_email": "alice@example.com",
325 "target_role": "staff",
326 "target_is_active": true,
327 "target_is_demo": false,
328 "target_demo_label": null,
329 "target_created_at": "2026-04-25 12:00 UTC",
330 "target_updated_at": "2026-04-25 12:30 UTC",
331 "groups": [],
332 "direct_perms": [],
333 "is_self": false,
334 "is_last_developer": false,
335 "can_edit": true,
336 "can_delete": true,
337 "csrf_token": "test-csrf",
338 })
339 }
340
341 #[test]
346 fn icon_function_emits_inline_svg() {
347 let dir = tempdir();
349 let admin_dir = dir.join("admin");
350 std::fs::create_dir_all(&admin_dir).unwrap();
351 std::fs::write(
352 admin_dir.join("icon_test.html"),
353 r#"<div>{{ icon("home", class="sidebar-icon") }}</div>"#,
354 )
355 .unwrap();
356
357 let t = Templates::new(Some(dir.clone())).unwrap();
358 let body = t.render("admin/icon_test.html", &Empty {}).unwrap();
359 assert!(
360 body.contains("<svg"),
361 "icon() must emit raw <svg> markup, not escape it"
362 );
363 assert!(body.contains(r#"class="sidebar-icon""#));
364 assert!(body.contains(r#"viewBox="0 0 24 24""#));
365 assert!(body.contains(r#"stroke="currentColor""#));
366
367 std::fs::write(
369 admin_dir.join("icon_missing.html"),
370 r#"<span>{{ icon("not-real") }}</span>"#,
371 )
372 .unwrap();
373 let body = t.render("admin/icon_missing.html", &Empty {}).unwrap();
374 assert_eq!(body.trim(), "<span></span>", "missing icon must be silent");
375
376 let _ = std::fs::remove_dir_all(&dir);
377 }
378
379 #[test]
380 fn user_view_renders_with_groups() {
381 let t = Templates::new(None).unwrap();
382 let mut ctx = view_ctx_base();
383 ctx["groups"] = serde_json::json!([
384 { "name": "Auditors", "description": "read-only audit access" },
385 { "name": "Content Editors", "description": "" },
386 ]);
387 let body = t.render("admin/user_view.html", &ctx).unwrap();
388 assert!(
389 body.contains("Group memberships (2)"),
390 "membership count must reflect the groups list length"
391 );
392 assert!(body.contains("Auditors"));
393 assert!(body.contains("Content Editors"));
394 assert!(body.contains("read-only audit access"));
397 }
398
399 #[test]
400 fn user_view_without_groups_shows_empty_message() {
401 let t = Templates::new(None).unwrap();
402 let body = t.render("admin/user_view.html", &view_ctx_base()).unwrap();
403 assert!(
404 body.contains("No group memberships"),
405 "empty-state copy must appear when the groups list is empty"
406 );
407 assert!(body.contains("Group memberships (0)"));
408 }
409
410 fn assert_delete_is_disabled_span(body: &str, expected_tooltip: &str) {
416 assert!(
417 body.contains(r#"<span class="btn-danger"#),
418 "Delete must render as a <span> with btn-danger class when guarded"
419 );
420 assert!(
421 !body.contains(r#"<a href="/admin/users/42/delete""#),
422 "Delete must NOT render as an <a href=…/delete> when guarded"
423 );
424 assert!(
425 body.contains(expected_tooltip),
426 "tooltip must contain {expected_tooltip:?} so the operator knows why"
427 );
428 }
429
430 #[test]
431 fn user_view_is_self_disables_delete_as_span() {
432 let t = Templates::new(None).unwrap();
433 let mut ctx = view_ctx_base();
434 ctx["is_self"] = serde_json::Value::Bool(true);
435 ctx["can_delete"] = serde_json::Value::Bool(false);
436 let body = t.render("admin/user_view.html", &ctx).unwrap();
437 assert_delete_is_disabled_span(&body, "Cannot delete your own account");
438 assert!(
441 body.contains(r#"<a href="/admin/users/42/edit""#),
442 "Edit button stays clickable on a self-view"
443 );
444 }
445
446 #[test]
447 fn user_view_last_developer_disables_delete() {
448 let t = Templates::new(None).unwrap();
449 let mut ctx = view_ctx_base();
450 ctx["is_last_developer"] = serde_json::Value::Bool(true);
451 ctx["can_delete"] = serde_json::Value::Bool(false);
452 let body = t.render("admin/user_view.html", &ctx).unwrap();
453 assert_delete_is_disabled_span(&body, "Cannot delete the last active developer");
454 }
455
456 #[test]
461 fn users_list_renders_row_clickable_links() {
462 let t = Templates::new(None).unwrap();
463 let ctx = serde_json::json!({
464 "page_title": "Users",
465 "users": [
466 { "id": 7, "email": "alice@example.com", "role": "staff",
467 "is_active": true, "created_at": "2026-04-01" },
468 { "id": 9, "email": "bob@example.com", "role": "developer",
469 "is_active": false, "created_at": "2026-04-02" },
470 ],
471 "csrf_token": "x",
472 });
473 let body = t.render("admin/users_list.html", &ctx).unwrap();
474
475 assert!(body.contains(r#"class="results row-clickable""#));
477
478 let count_for = |needle: &str| body.matches(needle).count();
480 assert_eq!(
481 count_for(r#"href="/admin/users/7/""#),
482 4,
483 "every cell in row 7 must link to the profile view (4 anchors)"
484 );
485 assert_eq!(
486 count_for(r#"href="/admin/users/9/""#),
487 4,
488 "every cell in row 9 must link to the profile view (4 anchors)"
489 );
490 assert_eq!(
493 count_for(r#"href="/admin/users/7/edit""#),
494 0,
495 "list rows must NOT link to /edit anymore — that lives behind the view"
496 );
497 }
498
499 #[test]
500 fn user_view_real_user_omits_demo_row() {
501 let t = Templates::new(None).unwrap();
502 let body = t.render("admin/user_view.html", &view_ctx_base()).unwrap();
504 assert!(
508 !body.contains("badge-warning\">staff @"),
509 "demo badge must NOT render for a real (non-demo) user"
510 );
511 assert!(
512 !body.contains("Demo account"),
513 "demo label must NOT appear for a real user"
514 );
515
516 let mut demo_ctx = view_ctx_base();
518 demo_ctx["target_is_demo"] = serde_json::Value::Bool(true);
519 demo_ctx["target_demo_label"] = serde_json::Value::String("staff @ rustio.local".into());
520 let demo_body = t.render("admin/user_view.html", &demo_ctx).unwrap();
521 assert!(
522 demo_body.contains("staff @ rustio.local"),
523 "demo label must render in the badge for a demo user"
524 );
525 }
526
527 #[test]
532 fn user_confirm_delete_submit_enabled_for_normal_user() {
533 let t = Templates::new(None).unwrap();
534 let ctx = serde_json::json!({
535 "user_id": 99,
536 "email": "throwaway@example.com",
537 "role": "staff",
538 "group_count": 0,
539 "session_count": 0,
540 "direct_perm_count": 0,
541 "is_self": false,
542 "is_last_developer": false,
543 "csrf_token": "test-csrf",
544 });
545 let body = t.render("admin/user_confirm_delete.html", &ctx).unwrap();
546 assert!(
551 body.contains(r#"<button type="submit" class="btn-danger">"#),
552 "submit button must render with btn-danger class"
553 );
554 assert!(
557 !body.contains(r#"<button type="submit" class="deletelink-button" disabled>"#),
558 "submit button must NOT render with `disabled` for a deletable user"
559 );
560 assert!(!body.contains("is the last active developer"));
562 assert!(!body.contains("your own account"));
563 }
564
565 fn tempdir() -> PathBuf {
566 let pid = std::process::id();
567 let nonce: u64 = std::time::SystemTime::now()
568 .duration_since(std::time::UNIX_EPOCH)
569 .unwrap()
570 .as_nanos() as u64;
571 let path = std::env::temp_dir().join(format!("rustio-tpl-{pid}-{nonce}"));
572 std::fs::create_dir_all(&path).unwrap();
573 path
574 }
575}