1use actix_session::Session;
4use actix_web::{http::StatusCode, HttpRequest, HttpResponse};
5use serde::Serialize;
6use std::collections::HashSet;
7use tera::{Context, Tera};
8use thiserror::Error;
9
10#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize)]
11pub struct TemplateName(pub &'static str);
12
13#[derive(Clone, Copy)]
14pub struct TemplateDef {
15 pub name: TemplateName,
16 pub source: &'static str,
17}
18
19#[derive(Clone, Copy, Default)]
20pub struct TemplateBundle {
21 pub templates: &'static [TemplateDef],
22}
23
24#[derive(Clone)]
25pub struct UiHost {
26 pub owner: &'static str,
27 pub templates: TemplateBundle,
28 pub admin_layout: TemplateName,
29 pub public_layout: TemplateName,
30}
31
32#[derive(Clone, Debug, Serialize)]
33#[serde(tag = "kind", content = "roles", rename_all = "snake_case")]
34pub enum Audience {
35 Authenticated,
36 AnyRole(&'static [&'static str]),
37}
38
39impl Audience {
40 fn visible(&self, roles: &[String]) -> bool {
41 match self {
42 Self::Authenticated => true,
43 Self::AnyRole(required) => required.iter().any(|role| roles.iter().any(|r| r == role)),
44 }
45 }
46}
47
48#[derive(Clone, Debug, Serialize)]
49pub struct AdminNavItem {
50 pub id: &'static str,
51 pub label: &'static str,
52 pub href: &'static str,
53 pub order: i16,
54 pub audience: Audience,
55}
56
57#[derive(Clone, Copy, Debug, Serialize)]
58#[serde(rename_all = "snake_case")]
59pub enum ActionMethod {
60 Get,
61 PostWithCsrf,
62}
63
64#[derive(Clone, Debug, Serialize)]
65pub struct AdminAction {
66 pub id: &'static str,
67 pub label: &'static str,
68 pub href: &'static str,
69 pub method: ActionMethod,
70 pub audience: Audience,
71}
72
73#[derive(Clone, Default)]
74pub struct UiContribution {
75 pub owner: &'static str,
76 pub templates: TemplateBundle,
77 pub navigation: Vec<AdminNavItem>,
78 pub actions: Vec<AdminAction>,
79 #[doc(hidden)]
80 pub duplicate_host: bool,
81}
82
83impl UiContribution {
84 pub(crate) fn duplicate_host(owner: &'static str) -> Self {
85 Self {
86 owner,
87 duplicate_host: true,
88 ..Self::default()
89 }
90 }
91}
92
93#[derive(Debug, Error, PartialEq, Eq)]
94pub enum UiError {
95 #[error("browser UI contributions from {0} require a registered UI host")]
96 MissingHost(String),
97 #[error("multiple UI hosts registered (including `{0}`)")]
98 MultipleHosts(&'static str),
99 #[error("template `{0}` is registered more than once")]
100 DuplicateTemplate(String),
101 #[error("capability `{owner}` may not register reserved template `{name}`")]
102 ReservedTemplate { owner: &'static str, name: String },
103 #[error("capability template `{name}` must use namespace `capabilities/{owner}/`")]
104 InvalidNamespace { owner: &'static str, name: String },
105 #[error("missing canonical template `{0}`")]
106 MissingCanonical(String),
107 #[error("invalid template registry: {0}")]
108 InvalidTemplate(String),
109 #[error("duplicate navigation id `{0}`")]
110 DuplicateNavigation(String),
111 #[error("duplicate action id `{0}`")]
112 DuplicateAction(String),
113 #[error("page context may not set reserved key `{0}`")]
114 ReservedContext(String),
115}
116
117#[derive(Debug)]
118pub struct UiRegistry {
119 tera: Tera,
120 admin_layout: TemplateName,
121 public_layout: TemplateName,
122 navigation: Vec<AdminNavItem>,
123 actions: Vec<AdminAction>,
124}
125
126impl UiRegistry {
127 pub fn build(
128 host: Option<&UiHost>,
129 contributions: &[UiContribution],
130 ) -> Result<Option<Self>, UiError> {
131 if contributions.iter().any(|c| c.duplicate_host) {
132 return Err(UiError::MultipleHosts(
133 contributions
134 .iter()
135 .find(|c| c.duplicate_host)
136 .unwrap()
137 .owner,
138 ));
139 }
140 let Some(host) = host else {
141 if contributions.is_empty() {
142 return Ok(None);
143 }
144 return Err(UiError::MissingHost(
145 contributions
146 .iter()
147 .map(|c| c.owner)
148 .collect::<Vec<_>>()
149 .join(", "),
150 ));
151 };
152 let mut defs = Vec::new();
153 let mut names = HashSet::new();
154 for def in host.templates.templates {
155 if !names.insert(def.name.0) {
156 return Err(UiError::DuplicateTemplate(def.name.0.into()));
157 }
158 defs.push((def.name.0, def.source));
159 }
160 for contribution in contributions {
161 for def in contribution.templates.templates {
162 let name = def.name.0;
163 if name.starts_with("layouts/") || name.starts_with("components/") {
164 return Err(UiError::ReservedTemplate {
165 owner: contribution.owner,
166 name: name.into(),
167 });
168 }
169 let prefix = format!("capabilities/{}/", contribution.owner);
170 if !name.starts_with(&prefix) {
171 return Err(UiError::InvalidNamespace {
172 owner: contribution.owner,
173 name: name.into(),
174 });
175 }
176 if !names.insert(name) {
177 return Err(UiError::DuplicateTemplate(name.into()));
178 }
179 defs.push((name, def.source));
180 }
181 }
182 for canonical in [host.admin_layout.0, host.public_layout.0] {
183 if !names.contains(canonical) {
184 return Err(UiError::MissingCanonical(canonical.into()));
185 }
186 }
187 let mut tera = Tera::default();
188 tera.add_raw_templates(defs)
189 .map_err(|e| UiError::InvalidTemplate(e.to_string()))?;
190 let mut navigation = contributions
191 .iter()
192 .flat_map(|c| c.navigation.clone())
193 .collect::<Vec<_>>();
194 unique(
195 navigation.iter().map(|v| v.id),
196 UiError::DuplicateNavigation,
197 )?;
198 navigation.sort_by_key(|v| (v.order, v.id));
199 let mut actions = contributions
200 .iter()
201 .flat_map(|c| c.actions.clone())
202 .collect::<Vec<_>>();
203 unique(actions.iter().map(|v| v.id), UiError::DuplicateAction)?;
204 actions.sort_by_key(|v| v.id);
205 Ok(Some(Self {
206 tera,
207 admin_layout: host.admin_layout,
208 public_layout: host.public_layout,
209 navigation,
210 actions,
211 }))
212 }
213
214 pub fn admin_layout(&self) -> TemplateName {
215 self.admin_layout
216 }
217 pub fn public_layout(&self) -> TemplateName {
218 self.public_layout
219 }
220
221 pub fn render(&self, page: UiPage, request: &HttpRequest, session: &Session) -> HttpResponse {
222 const RESERVED: &[&str] = &[
223 "app_name",
224 "environment",
225 "current_identity",
226 "admin_navigation",
227 "admin_actions",
228 "csrf_token",
229 "admin_layout",
230 "public_layout",
231 ];
232 for key in RESERVED {
233 if page.context.contains_key(key) {
234 return HttpResponse::InternalServerError()
235 .body(UiError::ReservedContext((*key).into()).to_string());
236 }
237 }
238 let mut context = page.context;
239 let identity = session
240 .get::<arc_auth_identity::SessionIdentity>("arc_auth_identity")
241 .ok()
242 .flatten();
243 let roles = identity.as_ref().map(|i| i.roles.as_slice()).unwrap_or(&[]);
244 let navigation = self
245 .navigation
246 .iter()
247 .filter(|v| identity.is_some() && v.audience.visible(roles))
248 .collect::<Vec<_>>();
249 let actions = self
250 .actions
251 .iter()
252 .filter(|v| identity.is_some() && v.audience.visible(roles))
253 .collect::<Vec<_>>();
254 context.insert(
255 "app_name",
256 &std::env::var("APP_NAME").unwrap_or_else(|_| env!("CARGO_PKG_NAME").into()),
257 );
258 context.insert(
259 "environment",
260 &std::env::var("APP_ENV").unwrap_or_else(|_| "development".into()),
261 );
262 context.insert("current_identity", &identity);
263 context.insert("admin_navigation", &navigation);
264 context.insert("admin_actions", &actions);
265 context.insert("csrf_token", &crate::helpers::csrf::get_csrf_token(session));
266 context.insert("admin_layout", &self.admin_layout.0);
267 context.insert("public_layout", &self.public_layout.0);
268 context.insert("request_path", request.path());
269 match self.tera.render(page.template.0, &context) {
270 Ok(body) => HttpResponse::build(page.status)
271 .content_type("text/html; charset=utf-8")
272 .body(body),
273 Err(error) => {
274 tracing::error!(template=page.template.0, %error, "UI render failed");
275 HttpResponse::InternalServerError().body("The page could not be rendered.")
276 }
277 }
278 }
279}
280
281fn unique<'a>(
282 values: impl Iterator<Item = &'a str>,
283 error: fn(String) -> UiError,
284) -> Result<(), UiError> {
285 let mut seen = HashSet::new();
286 for value in values {
287 if !seen.insert(value) {
288 return Err(error(value.into()));
289 }
290 }
291 Ok(())
292}
293
294pub struct UiPage {
295 pub template: TemplateName,
296 pub title: String,
297 pub context: Context,
298 pub status: StatusCode,
299}
300impl UiPage {
301 pub fn new(template: TemplateName, title: impl Into<String>) -> Self {
302 let title = title.into();
303 let mut context = Context::new();
304 context.insert("title", &title);
305 Self {
306 template,
307 title,
308 context,
309 status: StatusCode::OK,
310 }
311 }
312}
313
314#[derive(Clone, Debug, Serialize)]
315#[serde(rename_all = "snake_case")]
316pub enum FormMethod {
317 Get,
318 Post,
319}
320#[derive(Clone, Debug, Serialize)]
321pub struct FormSpec {
322 pub id: &'static str,
323 pub action: String,
324 pub method: FormMethod,
325 pub fields: Vec<FieldSpec>,
326 pub submit_label: String,
327 pub error: Option<String>,
328}
329#[derive(Clone, Debug, Serialize)]
330pub struct FieldSpec {
331 pub name: &'static str,
332 pub label: String,
333 pub kind: FieldKind,
334 pub value: FieldValue,
335 pub required: bool,
336 pub autocomplete: Option<&'static str>,
337 pub help: Option<String>,
338 pub error: Option<String>,
339}
340#[derive(Clone, Debug, Serialize)]
341#[serde(tag = "type", rename_all = "snake_case")]
342pub enum FieldKind {
343 Text,
344 Email,
345 Password,
346 Hidden,
347 Select { options: Vec<OptionSpec> },
348 MultiSelect { options: Vec<OptionSpec> },
349 Checkbox,
350}
351#[derive(Clone, Debug, Serialize)]
352#[serde(untagged)]
353pub enum FieldValue {
354 Empty,
355 Text(String),
356 Bool(bool),
357 Many(Vec<String>),
358}
359#[derive(Clone, Debug, Serialize)]
360pub struct OptionSpec {
361 pub value: String,
362 pub label: String,
363}
364impl FormSpec {
365 pub fn post(id: &'static str, action: impl Into<String>) -> Self {
366 Self {
367 id,
368 action: action.into(),
369 method: FormMethod::Post,
370 fields: vec![],
371 submit_label: "Save".into(),
372 error: None,
373 }
374 }
375 pub fn field(mut self, field: FieldSpec) -> Self {
376 self.fields.push(field);
377 self
378 }
379 pub fn submit(mut self, label: impl Into<String>) -> Self {
380 self.submit_label = label.into();
381 self
382 }
383}
384impl FieldSpec {
385 fn new(name: &'static str, label: impl Into<String>, kind: FieldKind) -> Self {
386 Self {
387 name,
388 label: label.into(),
389 kind,
390 value: FieldValue::Empty,
391 required: false,
392 autocomplete: None,
393 help: None,
394 error: None,
395 }
396 }
397 pub fn text(name: &'static str, label: impl Into<String>) -> Self {
398 Self::new(name, label, FieldKind::Text)
399 }
400 pub fn email(name: &'static str, label: impl Into<String>) -> Self {
401 Self::new(name, label, FieldKind::Email)
402 }
403 pub fn password(name: &'static str, label: impl Into<String>) -> Self {
404 Self::new(name, label, FieldKind::Password)
405 }
406 pub fn value(mut self, value: impl Into<String>) -> Self {
407 if !matches!(self.kind, FieldKind::Password) {
408 self.value = FieldValue::Text(value.into())
409 }
410 self
411 }
412 pub fn required(mut self) -> Self {
413 self.required = true;
414 self
415 }
416 pub fn autocomplete(mut self, v: &'static str) -> Self {
417 self.autocomplete = Some(v);
418 self
419 }
420}
421
422mod arc_auth_identity {
424 use serde::{Deserialize, Serialize};
425 #[derive(Deserialize, Serialize)]
426 pub struct SessionIdentity {
427 pub id: String,
428 pub name: String,
429 pub email: String,
430 pub active: bool,
431 pub roles: Vec<String>,
432 }
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438 const HOST: &[TemplateDef] = &[
439 TemplateDef {
440 name: TemplateName("layouts/admin.html"),
441 source: "{% block content %}{% endblock content %}",
442 },
443 TemplateDef {
444 name: TemplateName("layouts/public.html"),
445 source: "{% block content %}{% endblock content %}",
446 },
447 ];
448 fn host() -> UiHost {
449 UiHost {
450 owner: "app",
451 templates: TemplateBundle { templates: HOST },
452 admin_layout: TemplateName("layouts/admin.html"),
453 public_layout: TemplateName("layouts/public.html"),
454 }
455 }
456 fn contribution(owner: &'static str) -> UiContribution {
457 UiContribution {
458 owner,
459 templates: TemplateBundle::default(),
460 navigation: vec![],
461 actions: vec![],
462 duplicate_host: false,
463 }
464 }
465
466 #[test]
467 fn api_only_needs_no_host() {
468 assert!(UiRegistry::build(None, &[]).unwrap().is_none())
469 }
470 #[test]
471 fn contribution_requires_host() {
472 assert!(matches!(
473 UiRegistry::build(None, &[contribution("auth")]),
474 Err(UiError::MissingHost(_))
475 ))
476 }
477 #[test]
478 fn multiple_hosts_fail() {
479 let duplicate = UiContribution::duplicate_host("other");
480 assert_eq!(
481 UiRegistry::build(Some(&host()), &[duplicate]).unwrap_err(),
482 UiError::MultipleHosts("other")
483 )
484 }
485 #[test]
486 fn reserved_capability_template_fails() {
487 const BAD: &[TemplateDef] = &[TemplateDef {
488 name: TemplateName("layouts/bad.html"),
489 source: "",
490 }];
491 let mut c = contribution("auth");
492 c.templates = TemplateBundle { templates: BAD };
493 assert!(matches!(
494 UiRegistry::build(Some(&host()), &[c]),
495 Err(UiError::ReservedTemplate { .. })
496 ))
497 }
498 #[test]
499 fn duplicate_navigation_fails() {
500 let item = AdminNavItem {
501 id: "same",
502 label: "Same",
503 href: "/",
504 order: 0,
505 audience: Audience::Authenticated,
506 };
507 let mut a = contribution("a");
508 a.navigation.push(item.clone());
509 let mut b = contribution("b");
510 b.navigation.push(item);
511 assert_eq!(
512 UiRegistry::build(Some(&host()), &[a, b]).unwrap_err(),
513 UiError::DuplicateNavigation("same".into())
514 )
515 }
516 #[test]
517 fn navigation_is_deterministic() {
518 let mut c = contribution("a");
519 c.navigation = vec![
520 AdminNavItem {
521 id: "z",
522 label: "Z",
523 href: "/z",
524 order: 2,
525 audience: Audience::Authenticated,
526 },
527 AdminNavItem {
528 id: "a",
529 label: "A",
530 href: "/a",
531 order: 1,
532 audience: Audience::Authenticated,
533 },
534 ];
535 let registry = UiRegistry::build(Some(&host()), &[c]).unwrap().unwrap();
536 assert_eq!(
537 registry.navigation.iter().map(|i| i.id).collect::<Vec<_>>(),
538 vec!["a", "z"]
539 )
540 }
541 #[test]
542 fn password_values_are_suppressed() {
543 let field = FieldSpec::password("password", "Password").value("secret");
544 assert!(matches!(field.value, FieldValue::Empty))
545 }
546}