Skip to main content

arc_web/ui/
mod.rs

1//! Application-owned UI host and capability contribution contracts.
2
3use 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
422// Avoid a dependency cycle: this wire shape deliberately matches arc-auth-core::Identity.
423mod 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}